diff --git a/bun.lock b/bun.lock index d55db4568d34..70e22737fb42 100644 --- a/bun.lock +++ b/bun.lock @@ -275,6 +275,7 @@ "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/llm": "workspace:*", + "@opencode-ai/plugin": "workspace:*", "@openrouter/ai-sdk-provider": "2.9.0", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", @@ -624,6 +625,7 @@ "name": "@opencode-ai/plugin", "version": "1.17.6", "dependencies": { + "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", "zod": "catalog:", diff --git a/packages/core/package.json b/packages/core/package.json index ef73201ff605..1c577d8c3208 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -91,6 +91,7 @@ "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/llm": "workspace:*", + "@opencode-ai/plugin": "workspace:*", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index fabf7477d681..18e9e59c0ffe 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -1,7 +1,6 @@ export * as AgentV2 from "./agent" -import { Array, Context, Effect, Layer, Schema, Scope } from "effect" -import { castDraft, enableMapSet, type Draft } from "immer" +import { Array, Context, Effect, Layer, Schema, Scope, Types } from "effect" import { ModelV2 } from "./model" import { PermissionSchema } from "./permission/schema" import { ProviderV2 } from "./provider" @@ -49,21 +48,19 @@ export interface Selection { } type Data = { - agents: Map + agents: Map> default?: ID } -export type Editor = { +export type Draft = { list: () => readonly Info[] get: (id: ID) => Info | undefined default: (id: ID | undefined) => void - update: (id: ID, fn: (agent: Draft) => void) => void + update: (id: ID, fn: (agent: Types.DeepMutable) => void) => void remove: (id: ID) => void } -export interface Interface { - readonly transform: State.Interface["transform"] - readonly update: State.Interface["update"] +export interface Interface extends State.Transformable { readonly get: (id: ID) => Effect.Effect readonly default: () => Effect.Effect readonly resolve: (id?: ID | string) => Effect.Effect @@ -73,21 +70,19 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/Agent") {} -enableMapSet() - export const layer = Layer.effect( Service, Effect.gen(function* () { - const state = State.create({ + const state = State.create({ initial: () => ({ agents: new Map() }), - editor: (draft) => ({ + draft: (draft) => ({ list: () => Array.fromIterable(draft.agents.values()) as Info[], get: (id) => draft.agents.get(id), default: (id) => { draft.default = id }, update: (id, fn) => { - const current = draft.agents.get(id) ?? castDraft(Info.empty(id)) + const current = draft.agents.get(id) ?? (Info.empty(id) as Types.DeepMutable) if (!draft.agents.has(id)) draft.agents.set(id, current) fn(current) current.id = id @@ -113,7 +108,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, - update: state.update, + rebuild: state.rebuild, get: Effect.fn("AgentV2.get")(function* (id) { return state.get().agents.get(id) }), diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 4156db5c3617..fa6928fe5b4c 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,7 +1,6 @@ export * as Catalog from "./catalog" import { Array, Context, Effect, Layer, Option, Order, pipe, Schema, Scope, Stream } from "effect" -import { castDraft, enableMapSet, type Draft } from "immer" import { ModelV2 } from "./model" import { ModelRequest } from "./model-request" import { PluginV2 } from "./plugin" @@ -13,8 +12,8 @@ import { State } from "./state" import { Integration } from "./integration" export type ProviderRecord = { - provider: ProviderV2.Info - models: Map + provider: ProviderV2.MutableInfo + models: Map } export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID } @@ -42,16 +41,16 @@ type Data = { defaultModel?: DefaultModel } -export type Editor = { +export type Draft = { provider: { list: () => readonly ProviderRecord[] get: (providerID: ProviderV2.ID) => ProviderRecord | undefined - update: (providerID: ProviderV2.ID, fn: (provider: Draft) => void) => void + update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void remove: (providerID: ProviderV2.ID) => void } model: { get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined - update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft) => void) => void + update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void default: { get: () => DefaultModel | undefined @@ -60,8 +59,7 @@ export type Editor = { } } -export interface Interface { - readonly transform: State.Interface["transform"] +export interface Interface extends State.Transformable { readonly provider: { readonly get: (providerID: ProviderV2.ID) => Effect.Effect readonly all: () => Effect.Effect @@ -81,8 +79,6 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/Catalog") {} -enableMapSet() - export const layer = Layer.effect( Service, Effect.gen(function* () { @@ -126,26 +122,26 @@ export const layer = Layer.effect( return match } - const normalizeApi = (item: Draft | Draft) => { + const normalizeApi = (item: ProviderV2.MutableInfo | ModelV2.MutableInfo) => { if (typeof item.request.body.baseURL !== "string") return item.api.url = item.request.body.baseURL delete item.request.body.baseURL } - const state = State.create({ + const state = State.create({ initial: () => ({ providers: new Map() }), - editor: (draft) => { - const result: Editor = { + draft: (draft) => { + const result: Draft = { provider: { list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[], get: (providerID) => draft.providers.get(providerID), update: (providerID, fn) => { let current = draft.providers.get(providerID) if (!current) { - current = castDraft({ - provider: ProviderV2.Info.empty(providerID), - models: new Map(), - }) + current = { + provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo, + models: new Map(), + } draft.providers.set(providerID, current) } fn(current.provider) @@ -160,13 +156,14 @@ export const layer = Layer.effect( update: (providerID, modelID, fn) => { let record = draft.providers.get(providerID) if (!record) { - record = castDraft({ - provider: ProviderV2.Info.empty(providerID), - models: new Map(), - }) + record = { + provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo, + models: new Map(), + } draft.providers.set(providerID, record) } - const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID)) + const model = + record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo) if (!record.models.has(modelID)) record.models.set(modelID, model) fn(model) model.id = modelID @@ -186,8 +183,8 @@ export const layer = Layer.effect( } return result }, - finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) { - if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid) + finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) { + yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid) if (policy.hasStatements()) { for (const record of [...catalog.provider.list()]) { if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { @@ -204,14 +201,13 @@ export const layer = Layer.effect( (event) => event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID, ), - Stream.runForEach((event) => - state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"), - ), + Stream.runForEach(() => state.rebuild()), Effect.forkIn(scope, { startImmediately: true }), ) const result: Interface = { transform: state.transform, + rebuild: state.rebuild, provider: { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { @@ -250,7 +246,7 @@ export const layer = Layer.effect( Array.flatMap((record) => { return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider)) }), - Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), + Array.sortWith((item) => item.time.released, Order.flip(Order.Number)), ) }), @@ -274,7 +270,7 @@ export const layer = Layer.effect( return pipe( yield* result.model.available(), - Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), + Array.sortWith((item) => item.time.released, Order.flip(Order.Number)), Array.head, ) }), @@ -302,7 +298,7 @@ export const layer = Layer.effect( Array.map((model) => ({ model, cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999, - age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30), + age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30), small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()), })), Array.filter((item) => item.cost > 0 && item.age <= 18), diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index f6b8210be123..622702e9946f 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -1,7 +1,6 @@ export * as CommandV2 from "./command" -import { Context, Effect, Layer, Schema } from "effect" -import { castDraft, type Draft } from "immer" +import { Context, Effect, Layer, Schema, Types } from "effect" import { ModelV2 } from "./model" import { State } from "./state" @@ -15,19 +14,17 @@ export class Info extends Schema.Class("CommandV2.Info")({ }) {} export type Data = { - commands: Map + commands: Map> } -export type Editor = { +export type Draft = { list: () => readonly Info[] get: (name: string) => Info | undefined - update: (name: string, update: (command: Draft) => void) => void + update: (name: string, update: (command: Types.DeepMutable) => void) => void remove: (name: string) => void } -export interface Interface { - readonly transform: State.Interface["transform"] - readonly update: State.Interface["update"] +export interface Interface extends State.Transformable { readonly get: (name: string) => Effect.Effect readonly list: () => Effect.Effect } @@ -37,13 +34,13 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.sync(() => { - const state = State.create({ + const state = State.create({ initial: () => ({ commands: new Map() }), - editor: (draft) => ({ + draft: (draft) => ({ list: () => Array.from(draft.commands.values()) as Info[], get: (name) => draft.commands.get(name), update: (name, update) => { - const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" })) + const current = draft.commands.get(name) ?? (new Info({ name, template: "" }) as Types.DeepMutable) if (!draft.commands.has(name)) draft.commands.set(name, current) update(current) current.name = name @@ -55,7 +52,7 @@ export const layer = Layer.effect( }) return Service.of({ - update: state.update, + rebuild: state.rebuild, transform: state.transform, get: Effect.fn("CommandV2.get")(function* (name) { return state.get().commands.get(name) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 36534b0d3827..ffc268a0e24d 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -1,5 +1,6 @@ export * as ConfigAgentPlugin from "./agent" +import { define } from "@opencode-ai/plugin/v2/effect" import path from "path" import { Effect, Option, Schema } from "effect" import { AgentV2 } from "../../agent" @@ -8,7 +9,6 @@ import { ConfigAgent } from "../agent" import { ConfigMarkdown } from "../markdown" import { FSUtil } from "../../fs-util" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" import { ConfigAgentV1 } from "../../v1/config/agent" import { ConfigMigrateV1 } from "../../v1/config/migrate" @@ -33,70 +33,70 @@ const agentKeys = new Set([ "permissions", ]) -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("config-agent"), - effect: Effect.gen(function* () { - const agent = yield* AgentV2.Service +export const Plugin = define({ + id: "config-agent", + effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service - const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { - if (entry.type === "document") return Effect.succeed([entry]) - return Effect.gen(function* () { - const files = yield* discover(fs, entry.path) - return yield* Effect.forEach(files, (file) => - fs.readFileStringSafe(file.filepath).pipe( - Effect.map((content) => content && decode(file, content)), - Effect.catch(() => Effect.succeed(undefined)), - ), - ).pipe( - Effect.map((documents) => - documents.filter((document): document is Config.Document => document !== undefined), - ), - ) - }) - }).pipe(Effect.map((documents) => documents.flat())) + yield* ctx.agent.transform( + Effect.fn(function* (draft) { + const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") return Effect.succeed([entry]) + return Effect.gen(function* () { + const files = yield* discover(fs, entry.path) + return yield* Effect.forEach(files, (file) => + fs.readFileStringSafe(file.filepath).pipe( + Effect.map((content) => content && decode(file, content)), + Effect.catch(() => Effect.succeed(undefined)), + ), + ).pipe( + Effect.map((documents) => + documents.filter((document): document is Config.Document => document !== undefined), + ), + ) + }) + }).pipe(Effect.map((documents) => documents.flat())) + const global = documents.flatMap((document) => document.info.permissions ?? []) + const configuredDefault = Config.latest(documents, "default_agent") + if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault)) + for (const current of draft.list()) { + draft.update(current.id, (agent) => agent.permissions.push(...global)) + } - yield* agent.update((editor) => { - const global = documents.flatMap((document) => document.info.permissions ?? []) - const configuredDefault = Config.latest(documents, "default_agent") - if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault)) - for (const current of editor.list()) { - editor.update(current.id, (agent) => agent.permissions.push(...global)) - } + for (const document of documents) { + for (const [id, item] of Object.entries(document.info.agents ?? {})) { + const agentID = AgentV2.ID.make(id) + if (item.disabled) { + draft.remove(agentID) + continue + } - for (const document of documents) { - for (const [id, item] of Object.entries(document.info.agents ?? {})) { - const agentID = AgentV2.ID.make(id) - if (item.disabled) { - editor.remove(agentID) - continue + const exists = draft.get(agentID) !== undefined + draft.update(agentID, (agent) => { + if (!exists) agent.permissions.push(...global) + if (item.model !== undefined) { + const model = ModelV2.parse(item.model) + agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } + } + if (item.variant !== undefined && agent.model !== undefined) { + agent.model.variant = ModelV2.VariantID.make(item.variant) + } + if (item.request !== undefined) { + Object.assign(agent.request.headers, item.request.headers ?? {}) + Object.assign(agent.request.body, item.request.body ?? {}) + } + if (item.system !== undefined) agent.system = item.system + if (item.description !== undefined) agent.description = item.description + if (item.mode !== undefined) agent.mode = item.mode + if (item.hidden !== undefined) agent.hidden = item.hidden + if (item.color !== undefined) agent.color = item.color + if (item.steps !== undefined) agent.steps = item.steps + if (item.permissions !== undefined) agent.permissions.push(...item.permissions) + }) } - - const exists = editor.get(agentID) !== undefined - editor.update(agentID, (agent) => { - if (!exists) agent.permissions.push(...global) - if (item.model !== undefined) { - const model = ModelV2.parse(item.model) - agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } - } - if (item.variant !== undefined && agent.model !== undefined) { - agent.model.variant = ModelV2.VariantID.make(item.variant) - } - if (item.request !== undefined) { - Object.assign(agent.request.headers, item.request.headers ?? {}) - Object.assign(agent.request.body, item.request.body ?? {}) - } - if (item.system !== undefined) agent.system = item.system - if (item.description !== undefined) agent.description = item.description - if (item.mode !== undefined) agent.mode = item.mode - if (item.hidden !== undefined) agent.hidden = item.hidden - if (item.color !== undefined) agent.color = item.color - if (item.steps !== undefined) agent.steps = item.steps - if (item.permissions !== undefined) agent.permissions.push(...item.permissions) - }) } - } - }) + }), + ) }), }) diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index 7e71f306e894..a88c60559e94 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -1,52 +1,51 @@ export * as ConfigCommandPlugin from "./command" +import { define } from "@opencode-ai/plugin/v2/effect" import path from "path" import { Effect, Option, Schema } from "effect" import { CommandV2 } from "../../command" import { Config } from "../../config" import { FSUtil } from "../../fs-util" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" import { ConfigCommand } from "../command" import { ConfigMarkdown } from "../markdown" const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info) -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("config-command"), - effect: Effect.gen(function* () { - const command = yield* CommandV2.Service +export const Plugin = define({ + id: "config-command", + effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service - const transform = yield* command.transform() - const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { - if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) - return loadDirectory(fs, entry.path).pipe( - Effect.map((commands) => [ - { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, - ]), - ) - }).pipe(Effect.map((documents) => documents.flat())) - - yield* transform((editor) => { - for (const document of documents) { - for (const [name, command] of Object.entries(document.commands ?? {})) { - editor.update(name, (item) => { - item.template = command.template - if (command.description !== undefined) item.description = command.description - if (command.agent !== undefined) item.agent = command.agent - if (command.model !== undefined) { - const model = ModelV2.parse(command.model) - item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant } - } - if (command.variant !== undefined && item.model !== undefined) { - item.model.variant = ModelV2.VariantID.make(command.variant) - } - if (command.subtask !== undefined) item.subtask = command.subtask - }) + yield* ctx.command.transform( + Effect.fn(function* (draft) { + const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) + return loadDirectory(fs, entry.path).pipe( + Effect.map((commands) => [ + { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, + ]), + ) + }).pipe(Effect.map((documents) => documents.flat())) + for (const document of documents) { + for (const [name, command] of Object.entries(document.commands ?? {})) { + draft.update(name, (item) => { + item.template = command.template + if (command.description !== undefined) item.description = command.description + if (command.agent !== undefined) item.agent = command.agent + if (command.model !== undefined) { + const model = ModelV2.parse(command.model) + item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant } + } + if (command.variant !== undefined && item.model !== undefined) { + item.model.variant = ModelV2.VariantID.make(command.variant) + } + if (command.subtask !== undefined) item.subtask = command.subtask + }) + } } - } - }) + }), + ) }), }) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 47a3712e3ad4..0171fee37bb2 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -1,123 +1,124 @@ export * as ConfigProviderPlugin from "./provider" +import { define } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" -import { Catalog } from "../../catalog" import { Config } from "../../config" -import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { ModelRequest } from "../../model-request" -import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("config-provider"), - effect: Effect.gen(function* () { - const catalog = yield* Catalog.Service +export const Plugin = define({ + id: "config-provider", + effect: Effect.fn(function* (ctx) { const config = yield* Config.Service - const integrations = yield* Integration.Service - const transform = yield* catalog.transform() - const integrationTransform = yield* integrations.transform() - const entries = yield* config.entries() - const files = entries.filter((entry): entry is Config.Document => entry.type === "document") - const configuredIntegrations = new Set( - files.flatMap((file) => - Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])), - ), - ) - yield* integrationTransform((integrations) => { - for (const file of files) { - for (const [id, item] of Object.entries(file.info.providers ?? {})) { - const integrationID = Integration.ID.make(id) - if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue - integrations.update(integrationID, (integration) => { - integration.name = item.name ?? integration.name - }) - if (item.env !== undefined) { - integrations.method.update({ - integrationID, - method: { type: "env", names: [...item.env] }, + yield* ctx.integration.transform( + Effect.fn(function* (integrations) { + const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document") + const configuredIntegrations = new Set( + files.flatMap((file) => + Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => + provider.env === undefined ? [] : [id], + ), + ), + ) + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const integrationID = id + if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue + integrations.update(integrationID, (integration) => { + integration.name = item.name ?? integration.name }) + if (item.env !== undefined) { + integrations.method.update({ + integrationID, + method: { type: "env", names: [...item.env] }, + }) + } } } - } - }) + }), + ) - yield* transform((catalog) => { - const configuredDefault = Config.latest(entries, "model") - if (configuredDefault !== undefined) { - const model = ModelV2.parse(configuredDefault) - catalog.model.default.set(model.providerID, model.modelID) - } - for (const file of files) { - for (const [id, item] of Object.entries(file.info.providers ?? {})) { - const providerID = ProviderV2.ID.make(id) - catalog.provider.update(providerID, (provider) => { - if (item.name !== undefined) provider.name = item.name - if (item.api !== undefined) provider.api = { ...item.api } - if (item.request !== undefined) { - Object.assign(provider.request.headers, item.request.headers) - Object.assign(provider.request.body, item.request.body) - } - }) - const providerApi = catalog.provider.get(providerID)?.provider.api - const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined + yield* ctx.catalog.transform( + Effect.fn(function* (catalog) { + const entries = yield* config.entries() + const files = entries.filter((entry): entry is Config.Document => entry.type === "document") + const configuredDefault = Config.latest(entries, "model") + if (configuredDefault !== undefined) { + const model = ModelV2.parse(configuredDefault) + catalog.model.default.set(model.providerID, model.modelID) + } + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const providerID = id + catalog.provider.update(providerID, (provider) => { + if (item.name !== undefined) provider.name = item.name + if (item.api !== undefined) provider.api = { ...item.api } + if (item.request !== undefined) { + Object.assign(provider.request.headers, item.request.headers) + Object.assign(provider.request.body, item.request.body) + } + }) + const providerApi = catalog.provider.get(providerID)?.provider.api + const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined - for (const [id, config] of Object.entries(item.models ?? {})) { - catalog.model.update(providerID, ModelV2.ID.make(id), (model) => { - if (config.family !== undefined) model.family = config.family - if (config.name !== undefined) model.name = config.name - if (config.api !== undefined) model.api = { ...model.api, ...config.api } - const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage - if (config.capabilities !== undefined) { - model.capabilities = { - tools: config.capabilities.tools, - input: [...config.capabilities.input], - output: [...config.capabilities.output], + for (const [id, config] of Object.entries(item.models ?? {})) { + catalog.model.update(providerID, id, (model) => { + if (config.family !== undefined) model.family = config.family + if (config.name !== undefined) model.name = config.name + if (config.api !== undefined) model.api = { ...model.api, ...config.api } + const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage + if (config.capabilities !== undefined) { + model.capabilities = { + tools: config.capabilities.tools, + input: [...config.capabilities.input], + output: [...config.capabilities.output], + } } - } - if (config.request !== undefined) { - ModelRequest.assign(model.request, { - headers: config.request.headers, - ...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}), - }) - if (config.request.variant !== undefined) model.request.variant = config.request.variant - } - if (config.variants !== undefined) { - for (const variant of config.variants) { - let existing = model.variants.find((item) => item.id === variant.id) - if (!existing) { - existing = { - id: variant.id, - headers: {}, - body: {}, - generation: {}, - options: {}, + if (config.request !== undefined) { + ModelRequest.assign(model.request, { + headers: config.request.headers, + ...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}), + }) + if (config.request.variant !== undefined) model.request.variant = config.request.variant + } + if (config.variants !== undefined) { + for (const variant of config.variants) { + let existing = model.variants.find((item) => item.id === variant.id) + if (!existing) { + existing = { + id: variant.id, + headers: {}, + body: {}, + generation: {}, + options: {}, + } + model.variants.push(existing) } - model.variants.push(existing) + ModelRequest.assign(existing, { + headers: variant.headers, + ...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}), + }) } - ModelRequest.assign(existing, { - headers: variant.headers, - ...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}), - }) } - } - if (config.cost !== undefined) { - model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({ - tier: cost.tier && { ...cost.tier }, - input: cost.input, - output: cost.output, - cache: { - read: cost.cache?.read ?? 0, - write: cost.cache?.write ?? 0, - }, - })) - } - if (config.disabled !== undefined) model.enabled = !config.disabled - if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } - }) + if (config.cost !== undefined) { + model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({ + tier: cost.tier && { ...cost.tier }, + input: cost.input, + output: cost.output, + cache: { + read: cost.cache?.read ?? 0, + write: cost.cache?.write ?? 0, + }, + })) + } + if (config.disabled !== undefined) model.enabled = !config.disabled + if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } + }) + } } } - } - }) + }), + ) }), }) diff --git a/packages/core/src/config/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts index 22c7664996d3..f511736e11f7 100644 --- a/packages/core/src/config/plugin/reference.ts +++ b/packages/core/src/config/plugin/reference.ts @@ -1,57 +1,52 @@ export * as ConfigReferencePlugin from "./reference" +import { define } from "@opencode-ai/plugin/v2/effect" import path from "path" import { Effect } from "effect" import { Config } from "../../config" import { ConfigReference } from "../reference" -import { Global } from "../../global" -import { Location } from "../../location" -import { PluginV2 } from "../../plugin" import { Reference } from "../../reference" import { AbsolutePath } from "../../schema" -export const Plugin = { - id: PluginV2.ID.make("core/config-reference"), - effect: Effect.gen(function* () { +export const Plugin = define({ + id: "core/config-reference", + effect: Effect.fn(function* (ctx) { const config = yield* Config.Service - const global = yield* Global.Service - const location = yield* Location.Service - const references = yield* Reference.Service - const update = yield* references.transform() - const entries = new Map() - for (const doc of (yield* config.entries()).filter( - (entry): entry is Config.Document => entry.type === "document", - )) { - const directory = doc.path ? path.dirname(doc.path) : location.directory - for (const [name, entry] of Object.entries(doc.info.references ?? {})) { - if (!validAlias(name)) continue - entries.set( - name, - local(entry) - ? new Reference.LocalSource({ - type: "local", - path: AbsolutePath.make( - localPath(directory, global.home, typeof entry === "string" ? entry : entry.path), - ), - description: typeof entry === "string" ? undefined : entry.description, - hidden: typeof entry === "string" ? undefined : entry.hidden, - }) - : new Reference.GitSource({ - type: "git", - repository: typeof entry === "string" ? entry : entry.repository, - branch: typeof entry === "string" ? undefined : entry.branch, - description: typeof entry === "string" ? undefined : entry.description, - hidden: typeof entry === "string" ? undefined : entry.hidden, - }), - ) - } - } - - yield* update((editor) => { - for (const [name, source] of entries) editor.add(name, source) - }) + yield* ctx.reference.transform( + Effect.fn(function* (draft) { + const entries = new Map() + for (const doc of (yield* config.entries()).filter( + (entry): entry is Config.Document => entry.type === "document", + )) { + const directory = doc.path ? path.dirname(doc.path) : ctx.location.directory + for (const [name, entry] of Object.entries(doc.info.references ?? {})) { + if (!validAlias(name)) continue + entries.set( + name, + local(entry) + ? new Reference.LocalSource({ + type: "local", + path: AbsolutePath.make( + localPath(directory, ctx.path.home, typeof entry === "string" ? entry : entry.path), + ), + description: typeof entry === "string" ? undefined : entry.description, + hidden: typeof entry === "string" ? undefined : entry.hidden, + }) + : new Reference.GitSource({ + type: "git", + repository: typeof entry === "string" ? entry : entry.repository, + branch: typeof entry === "string" ? undefined : entry.branch, + description: typeof entry === "string" ? undefined : entry.description, + hidden: typeof entry === "string" ? undefined : entry.hidden, + }), + ) + } + } + for (const [name, source] of entries) draft.add(name, source) + }), + ) }), -} +}) function validAlias(name: string) { return name.length > 0 && !/[\/\s`,]/.test(name) diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index 30b7a8827664..9f6a99d8b1a4 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -1,48 +1,45 @@ export * as ConfigSkillPlugin from "./skill" +import { define } from "@opencode-ai/plugin/v2/effect" import path from "path" import { Effect } from "effect" import { Config } from "../../config" -import { Global } from "../../global" -import { Location } from "../../location" -import { PluginV2 } from "../../plugin" import { AbsolutePath } from "../../schema" import { SkillV2 } from "../../skill" -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("config-skill"), - effect: Effect.gen(function* () { +export const Plugin = define({ + id: "config-skill", + effect: Effect.fn(function* (ctx) { const config = yield* Config.Service - const global = yield* Global.Service - const location = yield* Location.Service - const skill = yield* SkillV2.Service - const transform = yield* skill.transform() - const entries = yield* config.entries() - const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) - const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) - - yield* transform((editor) => { - for (const directory of directories) { - editor.source( - new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), - ) - editor.source( - new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), - ) - } - for (const item of items) { - if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { - editor.source(new SkillV2.UrlSource({ type: "url", url: item })) - continue + yield* ctx.skill.transform( + Effect.fn(function* (draft) { + const entries = yield* config.entries() + const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) + const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) + for (const directory of directories) { + draft.source( + new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), + ) + draft.source( + new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), + ) + } + for (const item of items) { + if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { + draft.source(new SkillV2.UrlSource({ type: "url", url: item })) + continue + } + const expanded = item.startsWith("~/") ? path.join(ctx.path.home, item.slice(2)) : item + draft.source( + new SkillV2.DirectorySource({ + type: "directory", + path: AbsolutePath.make( + path.isAbsolute(expanded) ? expanded : path.join(ctx.location.directory, expanded), + ), + }), + ) } - const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item - editor.source( - new SkillV2.DirectorySource({ - type: "directory", - path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)), - }), - ) - } - }) + }), + ) }), }) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index ca626111a01a..29f8b9420ee8 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -1,7 +1,19 @@ export * as Integration from "./integration" -import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect" -import { castDraft, enableMapSet, type Draft } from "immer" +import { + Cause, + Clock, + Context, + Duration, + Effect, + Exit, + Layer, + Schedule, + Schema, + Scope, + SynchronizedRef, + Types, +} from "effect" import { Credential } from "./credential" import { IntegrationSchema } from "./integration/schema" import { withStatics } from "./schema" @@ -172,19 +184,19 @@ export type Ref = { } type Entry = { - ref: Ref - methods: Method[] - implementations: Map + ref: Types.DeepMutable + methods: Types.DeepMutable[] + implementations: Map> } type Data = { integrations: Map } -export type Editor = { +export type Draft = { list: () => readonly Ref[] get: (id: ID) => Ref | undefined - update: (id: ID, update: (integration: Draft) => void) => void + update: (id: ID, update: (integration: Types.DeepMutable) => void) => void remove: (id: ID) => void method: { list: (integrationID: ID) => readonly Method[] @@ -193,11 +205,8 @@ export type Editor = { } } -export interface Interface { +export interface Interface extends State.Transformable { /** Registers a scoped transform over the integration registry. */ - readonly transform: State.Interface["transform"] - /** Registers and immediately applies a scoped integration registry update. */ - readonly update: State.Interface["update"] /** Returns one integration with its methods and current connections. */ readonly get: (id: ID) => Effect.Effect /** Returns all integrations with their methods and current connections. */ @@ -252,8 +261,6 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/Integration") {} -enableMapSet() - const attemptLifetime = Duration.toMillis(Duration.minutes(10)) const terminalRetention = Duration.toMillis(Duration.minutes(1)) const scrubInterval = Duration.seconds(30) @@ -284,15 +291,17 @@ export const locationLayer = Layer.effect( const events = yield* EventV2.Service const scope = yield* Scope.Scope const attempts = SynchronizedRef.makeUnsafe(new Map()) - const state = State.create({ + const state = State.create({ initial: () => ({ integrations: new Map() }), - editor: (draft) => ({ + draft: (draft) => ({ list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[], get: (id) => draft.integrations.get(id)?.ref as Ref | undefined, update: (id, update) => { - const current = - draft.integrations.get(id) ?? - castDraft({ ref: { id, name: id } as Ref, methods: [], implementations: new Map() }) + const current = draft.integrations.get(id) ?? { + ref: { id, name: id }, + methods: [], + implementations: new Map(), + } if (!draft.integrations.has(id)) draft.integrations.set(id, current) update(current.ref) current.ref.id = id @@ -301,16 +310,14 @@ export const locationLayer = Layer.effect( method: { list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [], update: (implementation) => { - const current = - draft.integrations.get(implementation.integrationID) ?? - castDraft({ - ref: { - id: implementation.integrationID, - name: implementation.integrationID, - } as Ref, - methods: [], - implementations: new Map(), - }) + const current = draft.integrations.get(implementation.integrationID) ?? { + ref: { + id: implementation.integrationID, + name: implementation.integrationID, + }, + methods: [], + implementations: new Map>(), + } if (!draft.integrations.has(implementation.integrationID)) { draft.integrations.set(implementation.integrationID, current) } @@ -319,10 +326,13 @@ export const locationLayer = Layer.effect( if (method.type !== "oauth" || implementation.method.type !== "oauth") return true return method.id === implementation.method.id }) - if (index === -1) current.methods.push(castDraft(implementation.method)) - else current.methods[index] = castDraft(implementation.method) + if (index === -1) current.methods.push(implementation.method as Types.DeepMutable) + else current.methods[index] = implementation.method as Types.DeepMutable if (isOAuthImplementation(implementation)) { - current.implementations.set(implementation.method.id, castDraft(implementation)) + current.implementations.set( + implementation.method.id, + implementation as Types.DeepMutable, + ) } }, remove: (integrationID, method) => { @@ -434,7 +444,7 @@ export const locationLayer = Layer.effect( return Service.of({ transform: state.transform, - update: state.update, + rebuild: state.rebuild, get: Effect.fn("Integration.get")(function* (id) { const entry = state.get().integrations.get(id) if (!entry) return undefined diff --git a/packages/core/src/model-request.ts b/packages/core/src/model-request.ts index f9f4f56936dd..5de1f9803dcd 100644 --- a/packages/core/src/model-request.ts +++ b/packages/core/src/model-request.ts @@ -33,7 +33,7 @@ export type Request = typeof Request.Type interface MutableRequest { headers: Record body: Record - generation?: Generation + generation?: Record options?: Record } diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 3b0beece55fe..f424fd671c45 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -1,5 +1,4 @@ -import { DateTime, Schema } from "effect" -import { DateTimeUtcFromMillis } from "effect/Schema" +import { Schema, Types } from "effect" import { ProviderV2 } from "./provider" import { ModelRequest } from "./model-request" @@ -69,7 +68,7 @@ export class Info extends Schema.Class("ModelV2.Info")({ ...ModelRequest.Request.fields, }).pipe(Schema.Array), time: Schema.Struct({ - released: DateTimeUtcFromMillis, + released: Schema.Finite, }), cost: Cost.pipe(Schema.Array), status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), @@ -103,7 +102,7 @@ export class Info extends Schema.Class("ModelV2.Info")({ }, variants: [], time: { - released: DateTime.makeUnsafe(0), + released: 0, }, cost: [], status: "active", @@ -116,6 +115,10 @@ export class Info extends Schema.Class("ModelV2.Info")({ } } +export type MutableInfo = Omit, "api"> & { + api: ProviderV2.MutableApi +} + export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { const [providerID, ...modelID] = input.split("/") return { diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index f3398e83911c..7f3a9e2af1c3 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -20,7 +20,7 @@ export class InstallFailedError extends Schema.TaggedErrorClass + readonly entrypoint?: string } export interface Interface { @@ -47,12 +47,11 @@ export function sanitize(pkg: string) { } const resolveEntryPoint = (name: string, dir: string): EntryPoint => { - let entrypoint: Option.Option + let entrypoint: string | undefined try { - const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) - entrypoint = Option.some(resolved) + entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) } catch { - entrypoint = Option.none() + entrypoint = undefined } return { directory: dir, @@ -130,7 +129,7 @@ export const layer = Layer.effect( const first = tree.edgesOut.values().next().value?.to if (!first) { const result = resolveEntryPoint(name, path.join(dir, "node_modules", name)) - if (Option.isSome(result.entrypoint)) return result + if (result.entrypoint) return result return yield* new InstallFailedError({ add: [pkg], dir }) } return resolveEntryPoint(first.name, first.path) @@ -261,11 +260,7 @@ export async function install(...args: Parameters) { } export async function add(...args: Parameters) { - const entry = await runPromise((svc) => svc.add(...args)) - return { - directory: entry.directory, - entrypoint: Option.getOrUndefined(entry.entrypoint), - } + return runPromise((svc) => svc.add(...args)) } export async function which(...args: Parameters) { diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index aaef65d3227d..c826ba513bf6 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -7,6 +7,7 @@ import type { ModelV2 } from "./model" import type { Catalog } from "./catalog" import { EventV2 } from "./event" import { KeyedMutex } from "./effect/keyed-mutex" +import { State } from "./state" export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) export type ID = typeof ID.Type @@ -22,7 +23,7 @@ export const Event = { type HookSpec = { "catalog.transform": { - input: Catalog.Editor + input: Catalog.Draft output: {} } "aisdk.language": { @@ -62,18 +63,16 @@ export type HookFunctions = { export type HookInput = HookSpec[Name]["input"] export type HookOutput = HookSpec[Name]["output"] -export type Effect = Effect.Effect - -export function define(input: { id: ID; effect: Effect.Effect }) { - return input -} - export interface Interface { readonly add: (input: { - id: ID + id: string effect: Effect.Effect }) => Effect.Effect readonly remove: (id: ID) => Effect.Effect + readonly hook: ( + name: Name, + callback: (input: Hooks[Name]) => Effect.Effect | void, + ) => Effect.Effect readonly triggerFor: ( id: ID, name: Name, @@ -97,35 +96,40 @@ export const layer = Layer.effect( hooks: HookFunctions scope: Scope.Closeable }[] = [] + let registrations: { + [Name in keyof Hooks]: { + name: Name + callback: (input: Hooks[Name]) => Effect.Effect | void + } + }[keyof Hooks][] = [] const events = yield* EventV2.Service const scope = yield* Scope.Scope const locks = KeyedMutex.makeUnsafe() const svc = Service.of({ add: Effect.fn("Plugin.add")(function* (input) { - yield* locks.withLock(input.id)( + const id = ID.make(input.id) + yield* locks.withLock(id)( Effect.gen(function* () { - const existing = hooks.find((item) => item.id === input.id) + const existing = hooks.find((item) => item.id === id) if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore) const childScope = yield* Scope.fork(scope) const result = yield* input.effect.pipe( Scope.provide(childScope), Effect.withSpan("Plugin.load", { attributes: { - "plugin.id": input.id, + "plugin.id": id, }, }), Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)), ) - hooks = [ - ...hooks.filter((item) => item.id !== input.id), - { - id: input.id, - hooks: result ?? {}, - scope: childScope, - }, - ] - yield* events.publish(Event.Added, { id: input.id }) + const next = { + id, + hooks: result ?? {}, + scope: childScope, + } + hooks = existing ? hooks.with(hooks.indexOf(existing), next) : [...hooks, next] + yield* events.publish(Event.Added, { id }) }), ) }), @@ -160,6 +164,12 @@ export const layer = Layer.effect( ) } + for (const item of registrations) { + if (item.name !== name) continue + const result = item.callback(event as never) + if (Effect.isEffect(result)) yield* result + } + for (const [field, draft] of draftEntries) { event[field] = finishDraft(draft) } @@ -175,6 +185,19 @@ export const layer = Layer.effect( }), ) }), + hook: Effect.fn("Plugin.hook")(function* (name, callback) { + const scope = yield* Scope.Scope + const registration = { name, callback } as (typeof registrations)[number] + let active = true + registrations = [...registrations, registration] + const dispose = Effect.sync(() => { + if (!active) return + active = false + registrations = registrations.filter((item) => item !== registration) + }) + yield* Scope.addFinalizer(scope, dispose) + return { dispose } + }), }) return svc }), diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index e8a8d8bc9d67..735ddd310727 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -1,12 +1,11 @@ export * as AgentPlugin from "./agent" import path from "path" +import { define } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" import { AgentV2 } from "../agent" import { Global } from "../global" -import { Location } from "../location" import { PermissionV2 } from "../permission" -import { PluginV2 } from "../plugin" const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*") const BUILD_SYSTEM = @@ -97,12 +96,10 @@ Rules: - If the conversation ends with an unanswered question to the user, preserve that exact question - If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary` -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("agent"), - effect: Effect.gen(function* () { - const agent = yield* AgentV2.Service - const location = yield* Location.Service - const worktree = location.directory +export const Plugin = define({ + id: "agent", + effect: Effect.fn(function* (ctx) { + const worktree = ctx.location.directory const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] const readonlyExternalDirectory: PermissionV2.Ruleset = [ { action: "external_directory", resource: "*", effect: "ask" }, @@ -122,8 +119,8 @@ export const Plugin = PluginV2.define({ { action: "read", resource: "*.env.example", effect: "allow" }, ] - yield* agent.update((editor) => { - editor.update(AgentV2.defaultID, (item) => { + yield* ctx.agent.transform((draft) => { + draft.update(AgentV2.defaultID, (item) => { item.description = "The default agent. Executes tools based on configured permissions." item.system ??= BUILD_SYSTEM item.mode = "primary" @@ -135,7 +132,7 @@ export const Plugin = PluginV2.define({ ) }) - editor.update(AgentV2.ID.make("plan"), (item) => { + draft.update(AgentV2.ID.make("plan"), (item) => { item.description = "Plan mode. Disallows all edit tools." item.mode = "primary" item.permissions.push( @@ -154,14 +151,14 @@ export const Plugin = PluginV2.define({ ) }) - editor.update(AgentV2.ID.make("general"), (item) => { + draft.update(AgentV2.ID.make("general"), (item) => { item.description = "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." item.mode = "subagent" item.permissions.push(...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }])) }) - editor.update(AgentV2.ID.make("explore"), (item) => { + draft.update(AgentV2.ID.make("explore"), (item) => { item.description = 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.' item.system = PROMPT_EXPLORE @@ -182,21 +179,21 @@ export const Plugin = PluginV2.define({ ) }) - editor.update(AgentV2.ID.make("compaction"), (item) => { + draft.update(AgentV2.ID.make("compaction"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_COMPACTION item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) }) - editor.update(AgentV2.ID.make("title"), (item) => { + draft.update(AgentV2.ID.make("title"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_TITLE item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) }) - editor.update(AgentV2.ID.make("summary"), (item) => { + draft.update(AgentV2.ID.make("summary"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_SUMMARY diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index cc7b0c247a9e..4dc056ac75ab 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -1,7 +1,18 @@ export * as PluginBoot from "./boot" -import { Context, Deferred, Effect, Layer } from "effect" -import { Credential } from "../credential" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import type { Plugin as PublicPlugin, PluginHost } from "@opencode-ai/plugin/v2/effect" +import type { + AgentV2Info, + CommandV2Info, + Event as SDKEvent, + IntegrationInfo, + ModelV2Info, + ProviderV2Info, + ReferenceInfo, + SkillV2Info, +} from "@opencode-ai/sdk/v2/types" +import { Context, Deferred, Effect, Layer, Option, Schema, Stream } from "effect" import { Integration } from "../integration" import { AgentV2 } from "../agent" import { Catalog } from "../catalog" @@ -13,9 +24,11 @@ import { ConfigSkillPlugin } from "../config/plugin/skill" import { ConfigReferencePlugin } from "../config/plugin/reference" import { EventV2 } from "../event" import { FSUtil } from "../fs-util" +import { FileSystem } from "../filesystem" import { Global } from "../global" import { Location } from "../location" import { ModelsDev } from "../models-dev" +import { ModelV2 } from "../model" import { Npm } from "../npm" import { PluginV2 } from "../plugin" import { AgentPlugin } from "./agent" @@ -26,29 +39,32 @@ import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" import { SkillV2 } from "../skill" import { Reference } from "../reference" +import { ProviderV2 } from "../provider" -type Plugin = { - id: PluginV2.ID - effect: PluginV2.Effect< - | Catalog.Service - | CommandV2.Service - | Credential.Service - | Integration.Service - | AgentV2.Service - | Npm.Service - | EventV2.Service - | FSUtil.Service - | Global.Service - | Location.Service - | PluginV2.Service - | Config.Service - | ModelsDev.Service - | SkillV2.Service - | Reference.Service - > -} +type PublicAgentDraft = Parameters[0]>[0] +type PublicCatalogDraft = Parameters[0]>[0] +type PublicIntegrationDraft = Parameters[0]>[0] +type PublicIntegrationMethod = ReturnType[number] +type PublicSkillDraft = Parameters[0]>[0] +type PublicSkillSource = Parameters[0] +type EventMap = { [Item in SDKEvent as Item["type"]]: Item } +type PublicSDKHook = (event: { + readonly model: ModelV2Info + readonly package: string + readonly options: Record + sdk?: any +}) => Effect.Effect | void +type PublicLanguageHook = (event: { + readonly model: ModelV2Info + readonly sdk: any + readonly options: Record + language?: LanguageModelV3 +}) => Effect.Effect | void + +type InternalPlugin = PublicPlugin export interface Interface { + readonly add: (plugin: PublicPlugin) => Effect.Effect readonly wait: () => Effect.Effect } @@ -60,8 +76,7 @@ export const layer = Layer.effect( const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const integrations = yield* Integration.Service + const integration = yield* Integration.Service const agents = yield* AgentV2.Service const config = yield* Config.Service const location = yield* Location.Service @@ -69,31 +84,173 @@ export const layer = Layer.effect( const npm = yield* Npm.Service const events = yield* EventV2.Service const fs = yield* FSUtil.Service + const filesystem = yield* FileSystem.Service const global = yield* Global.Service const skill = yield* SkillV2.Service - const references = yield* Reference.Service + const reference = yield* Reference.Service + const host: PluginHost = { + agent: { + get: (id) => + agents + .get(AgentV2.ID.make(id)) + .pipe(Effect.map((value) => value && encode(AgentV2.Info, value))), + default: () => agents.default().pipe(Effect.map((value) => value && encode(AgentV2.Info, value))), + list: () => + agents.all().pipe(Effect.map((items) => items.map((value) => encode(AgentV2.Info, value)))), + rebuild: agents.rebuild, + transform: (callback) => agents.transform((draft) => callback(agentDraft(draft))), + }, + aisdk: { + hook: (name, callback) => { + if (name === "sdk") { + const run = callback as PublicSDKHook + return plugin.hook("aisdk.sdk", (event) => { + const output = { + model: encode(ModelV2.Info, event.model), + package: event.package, + options: event.options, + sdk: event.sdk, + } + const result = run(output) + return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), + ) + }) + } + const run = callback as PublicLanguageHook + return plugin.hook("aisdk.language", (event) => { + const output = { + model: encode(ModelV2.Info, event.model), + sdk: event.sdk, + options: event.options, + language: event.language, + } + const result = run(output) + return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + Effect.tap(() => Effect.sync(() => (event.language = output.language))), + ) + }) + }, + }, + catalog: { + provider: { + get: (id) => optional(catalog.provider.get(ProviderV2.ID.make(id)), ProviderV2.Info), + list: () => + catalog.provider + .all() + .pipe(Effect.map((items) => items.map((value) => encode(ProviderV2.Info, value)))), + available: () => + catalog.provider + .available() + .pipe(Effect.map((items) => items.map((value) => encode(ProviderV2.Info, value)))), + }, + model: { + get: (providerID, modelID) => + optional(catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), ModelV2.Info), + list: () => + catalog.model + .all() + .pipe(Effect.map((items) => items.map((value) => encode(ModelV2.Info, value)))), + available: () => + catalog.model + .available() + .pipe(Effect.map((items) => items.map((value) => encode(ModelV2.Info, value)))), + default: () => + catalog.model + .default() + .pipe( + Effect.map( + (value) => Option.map(value, (item) => encode(ModelV2.Info, item)).valueOrUndefined, + ), + ), + small: (providerID) => + catalog.model + .small(ProviderV2.ID.make(providerID)) + .pipe( + Effect.map( + (value) => Option.map(value, (item) => encode(ModelV2.Info, item)).valueOrUndefined, + ), + ), + }, + rebuild: catalog.rebuild, + transform: (callback) => catalog.transform((draft) => callback(catalogDraft(draft))), + }, + command: { + get: (name) => + commands.get(name).pipe(Effect.map((value) => value && encode(CommandV2.Info, value))), + list: () => + commands + .list() + .pipe(Effect.map((items) => items.map((value) => encode(CommandV2.Info, value)))), + rebuild: commands.rebuild, + transform: commands.transform, + }, + event: eventDomain(events), + filesystem, + integration: { + get: (id) => + integration + .get(Integration.ID.make(id)) + .pipe(Effect.map((value) => value && encode(Integration.Info, value))), + list: () => + integration + .list() + .pipe(Effect.map((items) => items.map((value) => encode(Integration.Info, value)))), + rebuild: integration.rebuild, + transform: (callback) => integration.transform((draft) => callback(integrationDraft(draft))), + }, + location, + npm, + path: { + home: global.home, + data: global.data, + cache: global.cache, + config: global.config, + state: global.state, + temp: global.tmp, + }, + reference: { + list: () => + reference + .list() + .pipe(Effect.map((items) => items.map((value) => encode(Reference.Info, value)))), + rebuild: reference.rebuild, + transform: reference.transform, + }, + skill: { + sources: () => + skill + .sources() + .pipe(Effect.map((items) => items.map((value) => encode(SkillV2.Source, value)))), + list: () => + skill.list().pipe(Effect.map((items) => items.map((value) => encode(SkillV2.Info, value)))), + rebuild: skill.rebuild, + transform: skill.transform, + }, + } const done = yield* Deferred.make() - const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { + const add = Effect.fn("PluginBoot.add")(function* (input: InternalPlugin) { yield* plugin.add({ id: input.id, - effect: input.effect.pipe( - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(CommandV2.Service, commands), - Effect.provideService(Credential.Service, credentials), - Effect.provideService(Integration.Service, integrations), - Effect.provideService(AgentV2.Service, agents), - Effect.provideService(Config.Service, config), - Effect.provideService(Location.Service, location), - Effect.provideService(ModelsDev.Service, modelsDev), - Effect.provideService(Npm.Service, npm), - Effect.provideService(EventV2.Service, events), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(Global.Service, global), - Effect.provideService(SkillV2.Service, skill), - Effect.provideService(Reference.Service, references), - Effect.provideService(PluginV2.Service, plugin), - ), + effect: input + .effect(host) + .pipe( + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(CommandV2.Service, commands), + Effect.provideService(Integration.Service, integration), + Effect.provideService(AgentV2.Service, agents), + Effect.provideService(Config.Service, config), + Effect.provideService(Location.Service, location), + Effect.provideService(ModelsDev.Service, modelsDev), + Effect.provideService(Npm.Service, npm), + Effect.provideService(EventV2.Service, events), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(FileSystem.Service, filesystem), + Effect.provideService(Global.Service, global), + Effect.provideService(SkillV2.Service, skill), + Effect.provideService(Reference.Service, reference), + ), }) }) @@ -101,15 +258,15 @@ export const layer = Layer.effect( yield* add(AgentPlugin.Plugin) yield* add(CommandPlugin.Plugin) yield* add(SkillPlugin.Plugin) - for (const item of ProviderPlugins) { - yield* add(item) - } yield* add(ModelsDevPlugin) yield* add(ConfigProviderPlugin.Plugin) yield* add(ConfigAgentPlugin.Plugin) yield* add(ConfigCommandPlugin.Plugin) yield* add(ConfigSkillPlugin.Plugin) yield* add(ConfigReferencePlugin.Plugin) + for (const item of ProviderPlugins) { + yield* add(item) + } }).pipe(Effect.withSpan("PluginBoot.boot")) yield* boot.pipe( @@ -119,6 +276,15 @@ export const layer = Layer.effect( ) return Service.of({ + add: (input) => + Deferred.await(done).pipe( + Effect.andThen( + plugin.add({ + id: input.id, + effect: input.effect(host), + }), + ), + ), wait: () => Deferred.await(done), }) }), @@ -132,4 +298,132 @@ export const locationLayer = layer.pipe( Layer.provideMerge(AgentV2.locationLayer), Layer.provideMerge(SkillV2.locationLayer), Layer.provideMerge(Reference.locationLayer), + Layer.provideMerge(FileSystem.locationLayer), ) + +function eventDomain(events: EventV2.Interface): PluginHost["event"] { + return { + subscribe: (type: Type): Stream.Stream => + Stream.unwrap( + Effect.sync(() => { + const definition = EventV2.registry.get(type) + if (!definition) throw new Error(`Unknown event type: ${type}`) + const encode = Schema.encodeUnknownSync(definition.data as Schema.Codec) + return events.subscribe(definition).pipe( + Stream.map( + (event) => + ({ + id: event.id, + type: event.type, + properties: encode(event.data), + }) as unknown as EventMap[Type], + ), + ) + }), + ), + } +} + +function agentDraft(draft: AgentV2.Draft): PublicAgentDraft { + return { + list: () => draft.list().map((value) => encode(AgentV2.Info, value)), + get: (id) => { + const value = draft.get(AgentV2.ID.make(id)) + return value && encode(AgentV2.Info, value) + }, + default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)), + update: (id, update) => + draft.update(AgentV2.ID.make(id), (value) => { + const encoded = encode(AgentV2.Info, value) + update(encoded) + Object.assign(value, Schema.decodeUnknownSync(AgentV2.Info)(encoded)) + }), + remove: (id) => draft.remove(AgentV2.ID.make(id)), + } +} + +function catalogDraft(draft: Catalog.Draft): PublicCatalogDraft { + return { + provider: { + list: () => + draft.provider.list().map((record) => ({ + provider: encode(ProviderV2.Info, record.provider), + models: new Map(Array.from(record.models, ([id, model]) => [id, encode(ModelV2.Info, model)])), + })), + get: (providerID) => { + const record = draft.provider.get(ProviderV2.ID.make(providerID)) + if (!record) return + return { + provider: encode(ProviderV2.Info, record.provider), + models: new Map(Array.from(record.models, ([id, model]) => [id, encode(ModelV2.Info, model)])), + } + }, + update: (providerID, update) => + draft.provider.update(ProviderV2.ID.make(providerID), (value) => { + const encoded = encode(ProviderV2.Info, value) + update(encoded) + Object.assign(value, Schema.decodeUnknownSync(ProviderV2.Info)(encoded)) + }), + remove: (providerID) => draft.provider.remove(ProviderV2.ID.make(providerID)), + }, + model: { + get: (providerID, modelID) => { + const value = draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)) + return value && encode(ModelV2.Info, value) + }, + update: (providerID, modelID, update) => + draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), (value) => { + const encoded = encode(ModelV2.Info, value) + update(encoded) + Object.assign(value, Schema.decodeUnknownSync(ModelV2.Info)(encoded)) + }), + remove: (providerID, modelID) => draft.model.remove(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + default: { + get: draft.model.default.get, + set: (providerID, modelID) => draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + }, + }, + } +} + +function integrationDraft(draft: Integration.Draft): PublicIntegrationDraft { + return { + list: () => draft.list().map((value) => ({ id: value.id, name: value.name })), + get: (id) => draft.get(Integration.ID.make(id)), + update: (id, update) => draft.update(Integration.ID.make(id), update), + remove: (id) => draft.remove(Integration.ID.make(id)), + method: { + list: (id) => + draft.method + .list(Integration.ID.make(id)) + .map((method) => encode(Integration.Method, method)), + update: (input) => { + if (input.method.type === "env") { + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { type: "env", names: [...input.method.names] }, + }) + return + } + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { type: "key", label: input.method.label }, + }) + }, + remove: (id, method) => + draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)), + }, + } +} + +function encode(schema: Schema.Top, value: unknown): Output { + const result = Schema.encodeUnknownSync(schema as Schema.Codec)(value) + return structuredClone(result) as Output +} + +function optional(effect: Effect.Effect, schema: Schema.Top): Effect.Effect { + return effect.pipe( + Effect.option, + Effect.map((value) => Option.map(value, (item) => encode(schema, item)).valueOrUndefined), + ) +} diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 66386a2128e5..121bc0e6ccbb 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -1,26 +1,20 @@ export * as CommandPlugin from "./command" +import { define } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" -import { CommandV2 } from "../command" -import { Location } from "../location" -import { PluginV2 } from "../plugin" import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_REVIEW from "./command/review.txt" -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("command"), - effect: Effect.gen(function* () { - const command = yield* CommandV2.Service - const location = yield* Location.Service - const transform = yield* command.transform() - - yield* transform((editor) => { - editor.update("init", (command) => { - command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory) +export const Plugin = define({ + id: "command", + effect: Effect.fn(function* (ctx) { + yield* ctx.command.transform((draft) => { + draft.update("init", (command) => { + command.template = PROMPT_INITIALIZE.replace("${path}", ctx.location.project.directory) command.description = "guided AGENTS.md setup" }) - editor.update("review", (command) => { - command.template = PROMPT_REVIEW.replace("${path}", location.project.directory) + draft.update("review", (command) => { + command.template = PROMPT_REVIEW.replace("${path}", ctx.location.project.directory) command.description = "review changes [commit|branch|pr], defaults to uncommitted" command.subtask = true }) diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index a212d013ad10..34f46685007c 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,16 +1,13 @@ -import { DateTime, Effect, Scope, Stream } from "effect" -import { Catalog } from "../catalog" -import { Integration } from "../integration" -import { EventV2 } from "../event" +import { define } from "@opencode-ai/plugin/v2/effect" +import { Effect, Stream } from "effect" import { ModelV2 } from "../model" import { ModelRequest } from "../model-request" import { ModelsDev } from "../models-dev" -import { PluginV2 } from "../plugin" import { ProviderV2 } from "../provider" function released(date: string) { const time = Date.parse(date) - return DateTime.makeUnsafe(Number.isFinite(time) ? time : 0) + return Number.isFinite(time) ? time : 0 } function cost(input: ModelsDev.Model["cost"]) { @@ -51,22 +48,16 @@ function variants(model: ModelsDev.Model, packageName?: string) { }) } -export const ModelsDevPlugin = PluginV2.define({ - id: PluginV2.ID.make("models-dev"), - effect: Effect.gen(function* () { - const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service +export const ModelsDevPlugin = define({ + id: "models-dev", + effect: Effect.fn(function* (ctx) { const modelsDev = yield* ModelsDev.Service - const events = yield* EventV2.Service - const scope = yield* Scope.Scope - const transform = yield* catalog.transform() - const integrationTransform = yield* integrations.transform() - const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () { - const data = yield* modelsDev.get() - yield* integrationTransform((integrations) => { + yield* ctx.integration.transform( + Effect.fn(function* (integrations) { + const data = yield* modelsDev.get() for (const item of Object.values(data)) { if (item.env.length === 0) continue - const integrationID = Integration.ID.make(item.id) + const integrationID = item.id integrations.update(integrationID, (integration) => (integration.name = item.name)) integrations.method.update({ integrationID, @@ -77,8 +68,11 @@ export const ModelsDevPlugin = PluginV2.define({ method: { type: "env", names: [...item.env] }, }) } - }) - yield* transform((catalog) => { + }), + ) + yield* ctx.catalog.transform( + Effect.fn(function* (catalog) { + const data = yield* modelsDev.get() for (const item of Object.values(data)) { const providerID = ProviderV2.ID.make(item.id) catalog.provider.update(providerID, (provider) => { @@ -132,11 +126,10 @@ export const ModelsDevPlugin = PluginV2.define({ }) } } - }) - }) - yield* refresh() - yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( - Stream.runForEach(() => refresh()), + }), + ) + yield* ctx.event.subscribe("models-dev.refreshed").pipe( + Stream.runForEach(() => ctx.integration.rebuild().pipe(Effect.andThen(ctx.catalog.rebuild()))), Effect.forkScoped({ startImmediately: true }), ) }), diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts index fa5c0a91cfb6..a75d0c0d08bf 100644 --- a/packages/core/src/plugin/provider/alibaba.ts +++ b/packages/core/src/plugin/provider/alibaba.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const AlibabaPlugin = PluginV2.define({ - id: PluginV2.ID.make("alibaba"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const AlibabaPlugin = define({ + id: "alibaba", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/alibaba") return const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba")) evt.sdk = mod.createAlibaba(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index 9c7fd65665a6..fe7bc10365b7 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" type MantleSDK = { @@ -59,11 +59,11 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) { return sdk.responses(modelID) } -export const AmazonBedrockPlugin = PluginV2.define({ - id: PluginV2.ID.make("amazon-bedrock"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AmazonBedrockPlugin = define({ + id: "amazon-bedrock", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue @@ -77,7 +77,10 @@ export const AmazonBedrockPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return const options = { ...evt.options } const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE @@ -108,7 +111,10 @@ export const AmazonBedrockPlugin = PluginV2.define({ const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock")) evt.sdk = mod.createAmazonBedrock(options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") { evt.language = selectMantleModel(evt.sdk, evt.model.api.id) @@ -117,6 +123,6 @@ export const AmazonBedrockPlugin = PluginV2.define({ const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION evt.language = evt.sdk.languageModel(resolveModelID(evt.model.api.id, region)) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 9bd69fe036cf..7c36d6dd9bee 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const AnthropicPlugin = PluginV2.define({ - id: PluginV2.ID.make("anthropic"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AnthropicPlugin = define({ + id: "anthropic", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/anthropic") continue @@ -15,11 +15,14 @@ export const AnthropicPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) evt.sdk = mod.createAnthropic(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 173fd36621f6..9115dcefe3c9 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" function selectLanguage(sdk: any, modelID: string, useChat: boolean) { @@ -10,11 +10,11 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) { return sdk.languageModel(modelID) } -export const AzurePlugin = PluginV2.define({ - id: PluginV2.ID.make("azure"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AzurePlugin = define({ + id: "azure", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/azure") continue @@ -27,7 +27,10 @@ export const AzurePlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/azure") return if (evt.model.providerID === ProviderV2.ID.azure) { if ( @@ -43,19 +46,22 @@ export const AzurePlugin = PluginV2.define({ const mod = yield* Effect.promise(() => import("@ai-sdk/azure")) evt.sdk = mod.createAzure(evt.options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.azure) return evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) }), - } + ) }), }) -export const AzureCognitiveServicesPlugin = PluginV2.define({ - id: PluginV2.ID.make("azure-cognitive-services"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AzureCognitiveServicesPlugin = define({ + id: "azure-cognitive-services", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME if (!resourceName) return for (const item of evt.provider.list()) { @@ -67,10 +73,13 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({ }) } }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index f87194368732..f82f3eacc657 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -1,24 +1,27 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const CerebrasPlugin = PluginV2.define({ - id: PluginV2.ID.make("cerebras"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (ctx) { - for (const item of ctx.provider.list()) { +export const CerebrasPlugin = define({ + id: "cerebras", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { + for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/cerebras") continue - ctx.provider.update(item.provider.id, (provider) => { + evt.provider.update(item.provider.id, (provider) => { provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cerebras") return const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras")) evt.sdk = mod.createCerebras(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index afb3a183e50a..c8ba32abaae2 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -1,13 +1,14 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect, Option, Schema } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const CloudflareAIGatewayPlugin = PluginV2.define({ - id: PluginV2.ID.make("cloudflare-ai-gateway"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const CloudflareAIGatewayPlugin = define({ + id: "cloudflare-ai-gateway", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "ai-gateway-provider") return if (evt.options.baseURL) return @@ -31,7 +32,7 @@ export const CloudflareAIGatewayPlugin = PluginV2.define({ }, } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 10f3f5200a92..3904ee5b833d 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -1,16 +1,16 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" const providerID = ProviderV2.ID.make("cloudflare-workers-ai") -export const CloudflareWorkersAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("cloudflare-workers-ai"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const CloudflareWorkersAIPlugin = define({ + id: "cloudflare-workers-ai", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { const item = evt.provider.get(providerID) if (!item) return evt.provider.update(item.provider.id, (provider) => { @@ -20,7 +20,10 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({ if (accountId) provider.api.url = workersEndpoint(accountId) }) }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return if (evt.package !== "@ai-sdk/openai-compatible") return @@ -34,11 +37,14 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({ }) as any, ) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return evt.language = evt.sdk.languageModel(evt.model.api.id) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cohere.ts b/packages/core/src/plugin/provider/cohere.ts index 991c370d1751..df9f64685d00 100644 --- a/packages/core/src/plugin/provider/cohere.ts +++ b/packages/core/src/plugin/provider/cohere.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const CoherePlugin = PluginV2.define({ - id: PluginV2.ID.make("cohere"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const CoherePlugin = define({ + id: "cohere", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cohere") return const mod = yield* Effect.promise(() => import("@ai-sdk/cohere")) evt.sdk = mod.createCohere(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/deepinfra.ts b/packages/core/src/plugin/provider/deepinfra.ts index bbd42f6e283b..2f62029a57bc 100644 --- a/packages/core/src/plugin/provider/deepinfra.ts +++ b/packages/core/src/plugin/provider/deepinfra.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const DeepInfraPlugin = PluginV2.define({ - id: PluginV2.ID.make("deepinfra"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const DeepInfraPlugin = define({ + id: "deepinfra", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/deepinfra") return const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra")) evt.sdk = mod.createDeepInfra(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts index e5abc7009e30..4ab7c738da12 100644 --- a/packages/core/src/plugin/provider/dynamic.ts +++ b/packages/core/src/plugin/provider/dynamic.ts @@ -1,19 +1,18 @@ -import { Npm } from "../../npm" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { pathToFileURL } from "url" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const DynamicProviderPlugin = PluginV2.define({ - id: PluginV2.ID.make("dynamic-provider"), - effect: Effect.gen(function* () { - const npm = yield* Npm.Service - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const DynamicProviderPlugin = define({ + id: "dynamic-provider", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.sdk) return const installedPath = evt.package.startsWith("file://") ? evt.package - : Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint) + : (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) const mod = yield* Effect.promise(async () => { @@ -26,6 +25,6 @@ export const DynamicProviderPlugin = PluginV2.define({ evt.sdk = mod[match](evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/gateway.ts b/packages/core/src/plugin/provider/gateway.ts index 5b08ad9ef5e2..6e8f91861083 100644 --- a/packages/core/src/plugin/provider/gateway.ts +++ b/packages/core/src/plugin/provider/gateway.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const GatewayPlugin = PluginV2.define({ - id: PluginV2.ID.make("gateway"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GatewayPlugin = define({ + id: "gateway", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/gateway") return const mod = yield* Effect.promise(() => import("@ai-sdk/gateway")) evt.sdk = mod.createGateway(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 1fc7c0c79991..6adc366c04f1 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" function shouldUseResponses(modelID: string) { @@ -11,16 +11,31 @@ function shouldUseResponses(modelID: string) { return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") } -export const GithubCopilotPlugin = PluginV2.define({ - id: PluginV2.ID.make("github-copilot"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GithubCopilotPlugin = define({ + id: "github-copilot", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { + const item = evt.provider.get(ProviderV2.ID.githubCopilot) + if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return + evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { + // This chat-only alias conflicts with the Copilot GPT-5 Responses route, + // so hide it only for Copilot rather than for every provider catalog. + model.enabled = false + }) + }), + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/github-copilot") return const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) evt.sdk = mod.createOpenaiCompatible(evt.options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { evt.language = evt.sdk.languageModel(evt.model.api.id) @@ -30,15 +45,6 @@ export const GithubCopilotPlugin = PluginV2.define({ ? evt.sdk.responses(evt.model.api.id) : evt.sdk.chat(evt.model.api.id) }), - "catalog.transform": Effect.fn(function* (evt) { - const item = evt.provider.get(ProviderV2.ID.githubCopilot) - if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return - evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { - // This chat-only alias conflicts with the Copilot GPT-5 Responses route, - // so hide it only for Copilot rather than for every provider catalog. - model.enabled = false - }) - }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 9de090a95d67..70af07164631 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,14 +1,15 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" -export const GitLabPlugin = PluginV2.define({ - id: PluginV2.ID.make("gitlab"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GitLabPlugin = define({ + id: "gitlab", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "gitlab-ai-provider") return const mod = yield* Effect.promise(() => import("gitlab-ai-provider")) evt.sdk = mod.createGitLab({ @@ -30,7 +31,10 @@ export const GitLabPlugin = PluginV2.define({ }, }) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.gitlab) return const featureFlags = typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {} @@ -58,6 +62,6 @@ export const GitLabPlugin = PluginV2.define({ featureFlags, }) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index a7168d59add7..e3d429504735 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" function resolveProject(options: Record) { @@ -54,11 +54,11 @@ function authFetch(fetchWithRuntimeOptions?: unknown) { } } -export const GoogleVertexPlugin = PluginV2.define({ - id: PluginV2.ID.make("google-vertex"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const GoogleVertexPlugin = define({ + id: "google-vertex", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if ( @@ -83,7 +83,10 @@ export const GoogleVertexPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { evt.options.fetch = authFetch(evt.options.fetch) return @@ -100,19 +103,22 @@ export const GoogleVertexPlugin = PluginV2.define({ location, }) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.googleVertex) return evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) }), - } + ) }), }) -export const GoogleVertexAnthropicPlugin = PluginV2.define({ - id: PluginV2.ID.make("google-vertex-anthropic"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const GoogleVertexAnthropicPlugin = define({ + id: "google-vertex-anthropic", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue @@ -132,7 +138,10 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google-vertex/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) const project = @@ -156,10 +165,13 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({ : {}), }) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/google.ts b/packages/core/src/plugin/provider/google.ts index 47e29c6b5d54..19b240b70167 100644 --- a/packages/core/src/plugin/provider/google.ts +++ b/packages/core/src/plugin/provider/google.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const GooglePlugin = PluginV2.define({ - id: PluginV2.ID.make("google"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GooglePlugin = define({ + id: "google", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google") return const mod = yield* Effect.promise(() => import("@ai-sdk/google")) evt.sdk = mod.createGoogleGenerativeAI(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/groq.ts b/packages/core/src/plugin/provider/groq.ts index f2052afd1a86..6a6e14ae6d6c 100644 --- a/packages/core/src/plugin/provider/groq.ts +++ b/packages/core/src/plugin/provider/groq.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const GroqPlugin = PluginV2.define({ - id: PluginV2.ID.make("groq"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GroqPlugin = define({ + id: "groq", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/groq") return const mod = yield* Effect.promise(() => import("@ai-sdk/groq")) evt.sdk = mod.createGroq(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index e293a66dad17..f57322a90300 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const KiloPlugin = PluginV2.define({ - id: PluginV2.ID.make("kilo"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const KiloPlugin = define({ + id: "kilo", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue @@ -16,6 +16,6 @@ export const KiloPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index 613f589ba5aa..9f4b887dd6bd 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -1,16 +1,14 @@ import { Effect } from "effect" -import { Integration } from "../../integration" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const LLMGatewayPlugin = PluginV2.define({ - id: PluginV2.ID.make("llmgateway"), - effect: Effect.gen(function* () { - const integrations = yield* Integration.Service - return { - "catalog.transform": Effect.fn(function* (evt) { +export const LLMGatewayPlugin = define({ + id: "llmgateway", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.disabled) continue - if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue + if (!(yield* ctx.integration.get(item.provider.id))) continue if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue @@ -21,6 +19,6 @@ export const LLMGatewayPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/mistral.ts b/packages/core/src/plugin/provider/mistral.ts index e7f0decb79ee..a799c2b451a2 100644 --- a/packages/core/src/plugin/provider/mistral.ts +++ b/packages/core/src/plugin/provider/mistral.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const MistralPlugin = PluginV2.define({ - id: PluginV2.ID.make("mistral"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const MistralPlugin = define({ + id: "mistral", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/mistral") return const mod = yield* Effect.promise(() => import("@ai-sdk/mistral")) evt.sdk = mod.createMistral(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 837fce2c0941..25f695d95260 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const NvidiaPlugin = PluginV2.define({ - id: PluginV2.ID.make("nvidia"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const NvidiaPlugin = define({ + id: "nvidia", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue @@ -17,6 +17,6 @@ export const NvidiaPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts index 76c33737066f..de2da085fe05 100644 --- a/packages/core/src/plugin/provider/openai-compatible.ts +++ b/packages/core/src/plugin/provider/openai-compatible.ts @@ -1,17 +1,18 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const OpenAICompatiblePlugin = PluginV2.define({ - id: PluginV2.ID.make("openai-compatible"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const OpenAICompatiblePlugin = define({ + id: "openai-compatible", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.sdk) return if (!evt.package.includes("@ai-sdk/openai-compatible")) return if (evt.options.includeUsage !== false) evt.options.includeUsage = true const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) evt.sdk = mod.createOpenAICompatible(evt.options as any) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index d58bd784f525..07fb2fec9752 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,29 +1,20 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" import { Integration } from "../../integration" import { browser, headless } from "./openai-auth" -export const OpenAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("openai"), - effect: Effect.gen(function* () { +export const OpenAIPlugin = define({ + id: "openai", + effect: Effect.fn(function* (ctx) { const integrations = yield* Integration.Service - yield* integrations.update((editor) => { - editor.method.update(browser) - editor.method.update(headless) + yield* integrations.transform((draft) => { + draft.method.update(browser) + draft.method.update(headless) }) - return { - "aisdk.sdk": Effect.fn(function* (evt) { - if (evt.package !== "@ai-sdk/openai") return - const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) - evt.sdk = mod.createOpenAI(evt.options) - }), - "aisdk.language": Effect.fn(function* (evt) { - if (evt.model.providerID !== ProviderV2.ID.openai) return - evt.language = evt.sdk.responses(evt.model.api.id) - }), - "catalog.transform": Effect.fn(function* (evt) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai") continue @@ -35,6 +26,21 @@ export const OpenAIPlugin = PluginV2.define({ }) } }), - } + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/openai") return + const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) + evt.sdk = mod.createOpenAI(evt.options) + }), + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.openai) return + evt.language = evt.sdk.responses(evt.model.api.id) + }), + ) }), }) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 56e71f822dd7..1414d5a1ecee 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -1,18 +1,16 @@ import { Effect } from "effect" -import { Integration } from "../../integration" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" -export const OpencodePlugin = PluginV2.define({ - id: PluginV2.ID.make("opencode"), - effect: Effect.gen(function* () { - const integrations = yield* Integration.Service +export const OpencodePlugin = define({ + id: "opencode", + effect: Effect.fn(function* (ctx) { let hasKey = false - return { - "catalog.transform": Effect.fn(function* (evt) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { const item = evt.provider.get(ProviderV2.ID.opencode) if (!item) return - const integration = yield* integrations.get(Integration.ID.make(item.provider.id)) + const integration = yield* ctx.integration.get(item.provider.id) hasKey = Boolean( process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey, ) @@ -27,6 +25,6 @@ export const OpencodePlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index bc56a11b54dc..81c4911d969e 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -1,12 +1,12 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const OpenRouterPlugin = PluginV2.define({ - id: PluginV2.ID.make("openrouter"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const OpenRouterPlugin = define({ + id: "openrouter", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue @@ -24,11 +24,14 @@ export const OpenRouterPlugin = PluginV2.define({ } } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@openrouter/ai-sdk-provider") return const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) evt.sdk = mod.createOpenRouter(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/perplexity.ts b/packages/core/src/plugin/provider/perplexity.ts index 2415ab7c1a22..c9e1873deebf 100644 --- a/packages/core/src/plugin/provider/perplexity.ts +++ b/packages/core/src/plugin/provider/perplexity.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const PerplexityPlugin = PluginV2.define({ - id: PluginV2.ID.make("perplexity"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const PerplexityPlugin = define({ + id: "perplexity", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/perplexity") return const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity")) evt.sdk = mod.createPerplexity(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index 47c8b7eaa8c1..b3675961bf2e 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -1,15 +1,14 @@ -import { Npm } from "../../npm" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { pathToFileURL } from "url" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" -export const SapAICorePlugin = PluginV2.define({ - id: PluginV2.ID.make("sap-ai-core"), - effect: Effect.gen(function* () { - const npm = yield* Npm.Service - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const SapAICorePlugin = define({ + id: "sap-ai-core", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return const serviceKey = process.env.AICORE_SERVICE_KEY ?? @@ -18,7 +17,7 @@ export const SapAICorePlugin = PluginV2.define({ const installedPath = evt.package.startsWith("file://") ? evt.package - : Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint) + : (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) const mod = yield* Effect.promise(async () => { @@ -35,10 +34,13 @@ export const SapAICorePlugin = PluginV2.define({ : {}, ) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return evt.language = evt.sdk(evt.model.api.id) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 0971f3518d17..48e5e73aad95 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise @@ -64,11 +64,12 @@ export function cortexFetch(upstream: FetchLike = fetch) { } } -export const SnowflakeCortexPlugin = PluginV2.define({ - id: PluginV2.ID.make("snowflake-cortex"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const SnowflakeCortexPlugin = define({ + id: "snowflake-cortex", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return const token = process.env.SNOWFLAKE_CORTEX_TOKEN ?? @@ -84,6 +85,6 @@ export const SnowflakeCortexPlugin = PluginV2.define({ fetch: cortexFetch(upstream) as typeof fetch, } as any) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/togetherai.ts b/packages/core/src/plugin/provider/togetherai.ts index b1870f266253..10eb849bafca 100644 --- a/packages/core/src/plugin/provider/togetherai.ts +++ b/packages/core/src/plugin/provider/togetherai.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const TogetherAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("togetherai"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const TogetherAIPlugin = define({ + id: "togetherai", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/togetherai") return const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai")) evt.sdk = mod.createTogetherAI(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/venice.ts b/packages/core/src/plugin/provider/venice.ts index 8a3b950245c6..2d2bc4dc91e0 100644 --- a/packages/core/src/plugin/provider/venice.ts +++ b/packages/core/src/plugin/provider/venice.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const VenicePlugin = PluginV2.define({ - id: PluginV2.ID.make("venice"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const VenicePlugin = define({ + id: "venice", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "venice-ai-sdk-provider") return const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider")) evt.sdk = mod.createVenice(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index a7e0bdf5a8c3..45f117158d25 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const VercelPlugin = PluginV2.define({ - id: PluginV2.ID.make("vercel"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const VercelPlugin = define({ + id: "vercel", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/vercel") continue @@ -15,11 +15,14 @@ export const VercelPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/vercel") return const mod = yield* Effect.promise(() => import("@ai-sdk/vercel")) evt.sdk = mod.createVercel(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 4e9d53e47a54..5fc10e8675be 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -1,20 +1,24 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "../../provider" -export const XAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("xai"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const XAIPlugin = define({ + id: "xai", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.hook( + "sdk", + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/xai") return const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) evt.sdk = mod.createXai(evt.options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.hook( + "language", + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("xai")) return evt.language = evt.sdk.responses(evt.model.api.id) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index a4f6a0ea01c3..497561e00ed3 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "@opencode-ai/plugin/v2/effect" -export const ZenmuxPlugin = PluginV2.define({ - id: PluginV2.ID.make("zenmux"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const ZenmuxPlugin = define({ + id: "zenmux", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue @@ -16,6 +16,6 @@ export const ZenmuxPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 7c89ac8e337b..9e994f873f61 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -2,22 +2,19 @@ export * as SkillPlugin from "./skill" +import { define } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" -import { PluginV2 } from "../plugin" import { AbsolutePath } from "../schema" import { SkillV2 } from "../skill" import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" } export const CustomizeOpencodeContent = customizeOpencodeContent -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("skill"), - effect: Effect.gen(function* () { - const skill = yield* SkillV2.Service - const transform = yield* skill.transform() - - yield* transform((editor) => { - editor.source( +export const Plugin = define({ + id: "skill", + effect: Effect.fn(function* (ctx) { + yield* ctx.skill.transform((draft) => { + draft.source( new SkillV2.EmbeddedSource({ type: "embedded", skill: new SkillV2.Info({ diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 3f5424a47f36..f12fd2c06884 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -1,7 +1,7 @@ export * as ProviderV2 from "./provider" import { withStatics } from "./schema" -import { Schema } from "effect" +import { Schema, Types } from "effect" export const ID = Schema.String.pipe( Schema.brand("ProviderV2.ID"), @@ -37,6 +37,10 @@ export const Native = Schema.Struct({ export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type")) export type Api = typeof Api.Type +export type MutableApi = T extends Api + ? Omit, "settings"> & + (undefined extends T["settings"] ? { settings?: any } : { settings: any }) + : never export const Request = Schema.Struct({ headers: Schema.Record(Schema.String, Schema.String), @@ -66,3 +70,5 @@ export class Info extends Schema.Class("ProviderV2.Info")({ }) } } + +export type MutableInfo = Omit, "api"> & { api: MutableApi } diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index 66eb160eb498..5ed46d76e130 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -1,7 +1,6 @@ export * as Reference from "./reference" -import { Context, Effect, Layer, Schema, Scope } from "effect" -import { castDraft } from "immer" +import { Context, Effect, Layer, Schema, Scope, Types } from "effect" import { Global } from "./global" import { EventV2 } from "./event" import { Repository } from "./repository" @@ -40,17 +39,16 @@ export class Info extends Schema.Class("Reference.Info")({ }) {} type Data = { - sources: Map + sources: Map> } -type Editor = { +type Draft = { add(name: string, source: Source): void remove(name: string): void list(): readonly [string, Source][] } -export interface Interface { - readonly transform: State.Interface["transform"] +export interface Interface extends State.Transformable { readonly list: () => Effect.Effect } @@ -64,18 +62,18 @@ export const layer = Layer.effect( const cache = yield* RepositoryCache.Service const scope = yield* Scope.Scope const materialized = new Map() - const state = State.create({ + const state = State.create({ initial: () => ({ sources: new Map() }), - editor: (draft) => ({ - add: (name, source) => draft.sources.set(name, castDraft(source)), + draft: (draft) => ({ + add: (name, source) => draft.sources.set(name, source as Types.DeepMutable), remove: (name) => draft.sources.delete(name), list: () => Array.from(draft.sources.entries()) as [string, Source][], }), - finalize: (editor) => + finalize: (draft) => Effect.gen(function* () { materialized.clear() const seen = new Map() - for (const [name, source] of editor.list()) { + for (const [name, source] of draft.list()) { if (source.type === "local") { materialized.set( name, @@ -128,6 +126,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, + rebuild: state.rebuild, list: Effect.fn("Reference.list")(function* () { return Array.from(materialized.values()) }), diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 259c8aff5e57..158fb4fab5e5 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -1,8 +1,7 @@ export * as SkillV2 from "./skill" import path from "path" -import { Context, Effect, Layer, Schema } from "effect" -import { castDraft } from "immer" +import { Context, Effect, Layer, Schema, Types } from "effect" import { AgentV2 } from "./agent" import { ConfigMarkdown } from "./config/markdown" import { FSUtil } from "./fs-util" @@ -65,16 +64,15 @@ const Frontmatter = Schema.Struct({ const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter) export type Data = { - sources: Source[] + sources: Types.DeepMutable[] } -export type Editor = { +export type Draft = { source: (source: Source) => void list: () => readonly Source[] } -export interface Interface { - readonly transform: State.Interface["transform"] +export interface Interface extends State.Transformable { readonly sources: () => Effect.Effect readonly list: () => Effect.Effect } @@ -87,12 +85,12 @@ export const layer = Layer.effect( const discovery = yield* SkillDiscovery.Service const fs = yield* FSUtil.Service - const state = State.create({ + const state = State.create({ initial: () => ({ sources: [] }), - editor: (draft) => ({ + draft: (draft) => ({ source: (source) => { if (draft.sources.some((item) => Source.equals(item, source))) return - draft.sources.push(castDraft(source)) + draft.sources.push(source as Types.DeepMutable) }, list: () => draft.sources as Source[], }), @@ -150,6 +148,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, + rebuild: state.rebuild, sources: Effect.fn("SkillV2.sources")(function* () { return state.get().sources }), diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index 7f1ae58d24cb..84f5e9713d0e 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -1,112 +1,104 @@ export * as State from "./state" import { Effect, Scope, Semaphore } from "effect" -import type { Draft, Objectish } from "immer" /** - * A replayable transform applied to an editor during rebuild. + * A replayable transform applied to a draft during rebuild. * - * Transforms are intentionally synchronous and mutation-shaped: domain editors - * hide the draft representation while preserving concise plugin/config code. + * Domain drafts expose readable and writable state while preserving concise + * plugin/config code. Transforms may perform Effects before returning. */ -export type Transform = (editor: Editor) => void -export type MakeEditor = (draft: Draft) => Editor +type TransformCallback = (draft: DraftApi) => Effect.Effect | void +export type MakeDraft = (state: State) => DraftApi -export interface Options { +export interface Registration { + readonly dispose: Effect.Effect +} + +export type Transform = ( + transform: TransformCallback, +) => Effect.Effect + +export type Rebuild = () => Effect.Effect + +export interface Transformable { + readonly transform: Transform + readonly rebuild: Rebuild +} + +export interface Options { /** Creates the base value for initial state and every scoped-transform rebuild. */ readonly initial: () => State - /** Wraps the mutable draft in a domain-specific editor. */ - readonly editor: MakeEditor - /** - * Completes every committed edit. - * - * For rebuilds, this runs after all active transforms have been replayed and - * before the rebuilt state becomes visible. For direct updates, this runs - * after the current state has already been edited. The optional reason is - * caller-defined metadata for exceptional update origins. - */ - readonly finalize?: (editor: Editor, reason?: string) => Effect.Effect + /** Wraps mutable state in a domain-specific draft API. */ + readonly draft: MakeDraft + /** Runs after all active transforms and before the rebuilt state becomes visible. */ + readonly finalize?: (draft: DraftApi) => Effect.Effect } -export interface Interface { +export interface Interface extends Transformable { readonly get: () => State /** - * Registers a scoped transform slot and returns the slot updater. - * - * Acquiring the slot has no visible effect until the returned updater is - * called. Each updater call replaces that slot's transform, then rebuilds the - * materialized state from `initial()` by replaying all active transforms in - * registration order. Closing the owning Scope removes the slot and rebuilds. - */ - readonly transform: () => Effect.Effect<(transform: Transform) => Effect.Effect, never, Scope.Scope> - /** Registers and applies a replayable transform in the current Scope. */ - readonly update: (update: Transform) => Effect.Effect - /** - * Mutates the current materialized state directly, once. - * - * This is not replayable transform state: a later rebuild starts again - * from `initial()` plus active transforms, so direct edits must be reserved - * for current-state adjustments that are intentionally outside the transform - * fold. + * Registers and applies a scoped transform. Closing the owning Scope removes + * the transform and rebuilds the materialized state. */ - readonly mutate: (update: (editor: Editor) => Effect.Effect, reason?: string) => Effect.Effect } -export function create(options: Options): Interface { +export function create(options: Options): Interface { let state = options.initial() - let transforms: { update: Transform }[] = [] + let transforms: { run: TransformCallback }[] = [] const semaphore = Semaphore.makeUnsafe(1) - const commit = Effect.fn("State.commit")(function* (next: State, reason?: string) { - const api = options.editor(next as Draft) - if (options.finalize) yield* options.finalize(api, reason) + const commit = Effect.fn("State.commit")(function* (next: State) { + const api = options.draft(next) + if (options.finalize) yield* options.finalize(api) state = next }) - const rebuild = Effect.fnUntraced(function* () { + const apply = (transform: TransformCallback, draft: DraftApi) => + Effect.suspend(() => { + const result = transform(draft) + return Effect.isEffect(result) ? Effect.asVoid(result).pipe(Effect.orDie) : Effect.void + }) + + const materialize = Effect.fnUntraced(function* () { const next = options.initial() - const api = options.editor(next as Draft) - for (const transform of transforms) - yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {})) + const api = options.draft(next) + for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.rebuild.update")) yield* commit(next) }) - const result: Interface = { + const rebuild = () => semaphore.withPermit(materialize()) + + const result: Interface = { get: () => state, - transform: Effect.fn("State.transform")(function* () { + transform: Effect.fn("State.transform")(function* (update) { const scope = yield* Scope.Scope return yield* Effect.uninterruptible( Effect.gen(function* () { - const transform = { update: (_editor: Editor) => {} } - transforms = [...transforms, transform] - yield* Scope.addFinalizer( - scope, + const transform = { run: update } + let active = true + const dispose = Effect.uninterruptible( semaphore.withPermit( - Effect.sync(() => { + Effect.suspend(() => { + if (!active) return Effect.void + active = false transforms = transforms.filter((item) => item !== transform) - }).pipe(Effect.andThen(rebuild())), + return materialize() + }), ), ) - return (update: Transform) => - Effect.uninterruptible( - semaphore.withPermit( - Effect.sync(() => { - transform.update = update - }).pipe(Effect.andThen(rebuild())), - ), - ) + yield* semaphore.withPermit( + Effect.sync(() => { + transforms = [...transforms, transform] + }), + ) + yield* Scope.addFinalizer(scope, dispose) + yield* rebuild() + return { dispose } }), ) }), - update: Effect.fn("State.update")(function* (update) { - const transform = yield* result.transform() - yield* transform(update) - }), - mutate: Effect.fn("State.mutate")(function* (update, reason) { - const api = options.editor(state as Draft) - yield* update(api) - if (options.finalize) yield* options.finalize(api, reason) - }, semaphore.withPermit), + rebuild, } return result } diff --git a/packages/core/src/tool/application-tools.ts b/packages/core/src/tool/application-tools.ts index 024c2006d047..5309e4b4ba78 100644 --- a/packages/core/src/tool/application-tools.ts +++ b/packages/core/src/tool/application-tools.ts @@ -1,7 +1,6 @@ export * as ApplicationTools from "./application-tools" import { Context, Effect, Layer, Scope } from "effect" -import { enableMapSet } from "immer" import { State } from "../state" import { Tool } from "./tool" @@ -9,7 +8,7 @@ type Data = { readonly entries: Map } -type Editor = { +type Draft = { readonly set: (name: string, entry: Entry) => void } @@ -27,14 +26,12 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ApplicationTools") {} -enableMapSet() - export const layer = Layer.effect( Service, Effect.gen(function* () { - const state = State.create({ + const state = State.create({ initial: () => ({ entries: new Map() }), - editor: (draft) => ({ + draft: (draft) => ({ set: (name, tool) => { draft.entries.set(name, tool) }, @@ -47,9 +44,8 @@ export const layer = Layer.effect( if (entries.length === 0) return yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true }) const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const) - const transform = yield* state.transform() - yield* transform((editor) => { - for (const [name, entry] of registrations) editor.set(name, entry) + yield* state.transform((draft) => { + for (const [name, entry] of registrations) draft.set(name, entry) }) }), entries: () => state.get().entries, diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index 9f46eca4e963..f8b8d4eb2edd 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -6,6 +6,7 @@ import { AgentPlugin } from "@opencode-ai/core/plugin/agent" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" +import { agentHost, host } from "./plugin/host" const it = testEffect(AgentV2.locationLayer) @@ -23,9 +24,7 @@ describe("AgentV2", () => { Effect.gen(function* () { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("reviewer") - const transform = yield* agent.transform() - - yield* transform((editor) => + yield* agent.transform((editor) => editor.update(id, (info) => { info.description = "Reviews code" info.mode = "subagent" @@ -41,19 +40,17 @@ describe("AgentV2", () => { Effect.gen(function* () { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("reviewer") - const transform = yield* agent.transform() - - yield* transform((editor) => - editor.update(id, (info) => { - info.description = "Old description" - info.hidden = true - }), - ) - yield* transform((editor) => + let description = "Old description" + let hidden = true + yield* agent.transform((editor) => editor.update(id, (info) => { - info.description = "New description" + info.description = description + info.hidden = hidden }), ) + description = "New description" + hidden = false + yield* agent.rebuild() expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false }) }), @@ -64,9 +61,7 @@ describe("AgentV2", () => { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("scoped") const scope = yield* Scope.make() - const transform = yield* agent.transform().pipe(Scope.provide(scope)) - - yield* transform((editor) => editor.update(id, () => {})) + yield* agent.transform((editor) => editor.update(id, () => {})).pipe(Scope.provide(scope)) expect(yield* agent.get(id)).toBeDefined() yield* Scope.close(scope, Exit.void) @@ -79,7 +74,7 @@ describe("AgentV2", () => { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("build") - yield* agent.update((editor) => + yield* agent.transform((editor) => editor.update(id, (info) => { info.mode = "primary" info.hidden = true @@ -95,10 +90,10 @@ describe("AgentV2", () => { const agent = yield* AgentV2.Service const id = AgentV2.ID.make("custom") - yield* agent.update((editor) => editor.update(id, () => {})) + yield* agent.transform((editor) => editor.update(id, () => {})) expect(yield* agent.get(id)).toEqual(AgentV2.Info.empty(id)) - yield* agent.update((editor) => editor.remove(id)) + yield* agent.transform((editor) => editor.remove(id)) expect(yield* agent.get(id)).toBeUndefined() }), ) @@ -106,11 +101,11 @@ describe("AgentV2", () => { it.effect("does not ambiently opt built-in agents into bash", () => Effect.gen(function* () { const agent = yield* AgentV2.Service - yield* AgentPlugin.Plugin.effect.pipe( - Effect.provideService( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("/project") })), - ), + yield* AgentPlugin.Plugin.effect( + host({ + agent: agentHost(agent), + location: location({ directory: AbsolutePath.make("/project") }), + }), ) const agents = yield* agent.all() diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 77a18e79aefa..a4442a283b69 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect" +import { Effect, Fiber, Layer, Option, Stream } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" @@ -41,7 +41,7 @@ describe("CatalogV2", () => { .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - yield* (yield* catalog.transform())((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) expect((yield* Fiber.join(updated)).length).toBe(1) }), @@ -76,8 +76,7 @@ describe("CatalogV2", () => { return Effect.gen(function* () { const catalog = yield* Catalog.Service - const transform = yield* catalog.transform() - yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) @@ -99,13 +98,13 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const integrations = yield* Integration.Service const providerID = ProviderV2.ID.make("test") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID: Integration.ID.make(providerID), method: { type: "env", names: ["CATALOG_TEST_API_KEY"] }, }), ) - yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {})) + yield* catalog.transform((editor) => editor.provider.update(providerID, () => {})) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID) }), @@ -121,9 +120,7 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const transform = yield* catalog.transform() - - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(providerID, (provider) => { provider.api = { type: "aisdk", @@ -147,9 +144,7 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.api = { type: "aisdk", @@ -183,9 +178,7 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.api = { type: "aisdk", @@ -211,8 +204,6 @@ describe("CatalogV2", () => { const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.make("test") const seen: unknown[] = [] - const transform = yield* catalog.transform() - yield* plugin.add({ id: PluginV2.ID.make("test"), effect: Effect.succeed({ @@ -226,7 +217,7 @@ describe("CatalogV2", () => { }), }), }) - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(providerID, (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } provider.request.body.baseURL = "https://provider.example.com" @@ -242,9 +233,7 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.make("test") - const transform = yield* catalog.transform() - - yield* transform((catalog) => + yield* catalog.transform((catalog) => catalog.provider.update(providerID, (provider) => { provider.name = "Before" }), @@ -302,9 +291,7 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, (provider) => { provider.request.headers.provider = "provider" provider.request.headers.shared = "provider" @@ -332,15 +319,13 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { - model.time.released = DateTime.makeUnsafe(1000) + model.time.released = 1000 }) catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => { - model.time.released = DateTime.makeUnsafe(2000) + model.time.released = 2000 }) }) @@ -354,25 +339,25 @@ describe("CatalogV2", () => { const providerID = ProviderV2.ID.make("test") const old = ModelV2.ID.make("old") const newest = ModelV2.ID.make("new") - const transform = yield* catalog.transform() - - const models = (catalog: Catalog.Editor) => { + const models = (catalog: Catalog.Draft) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, old, (model) => { - model.time.released = DateTime.makeUnsafe(1000) + model.time.released = 1000 }) catalog.model.update(providerID, newest, (model) => { - model.time.released = DateTime.makeUnsafe(2000) + model.time.released = 2000 }) } - yield* transform((catalog) => { + let configured = true + yield* catalog.transform((catalog) => { models(catalog) - catalog.model.default.set(providerID, old) + if (configured) catalog.model.default.set(providerID, old) }) expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(old) - yield* transform(models) + configured = false + yield* catalog.rebuild() expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(newest) }), ) @@ -384,9 +369,7 @@ describe("CatalogV2", () => { const enabledProvider = ProviderV2.ID.make("enabled") const disabledModel = ModelV2.ID.make("configured") const fallbackModel = ModelV2.ID.make("fallback") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(disabledProvider, (provider) => { provider.disabled = true }) @@ -407,21 +390,19 @@ describe("CatalogV2", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }] - model.time.released = DateTime.makeUnsafe(Date.now()) + model.time.released = Date.now() }) catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }] - model.time.released = DateTime.makeUnsafe(Date.now()) + model.time.released = Date.now() }) }) @@ -434,10 +415,8 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const policy = yield* Policy.Service const providerID = ProviderV2.ID.make("blocked") - const transform = yield* catalog.transform() - yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })]) - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("model"), () => {}) }) diff --git a/packages/core/test/command.test.ts b/packages/core/test/command.test.ts index f2175743e42b..8c2c5843fef7 100644 --- a/packages/core/test/command.test.ts +++ b/packages/core/test/command.test.ts @@ -11,8 +11,7 @@ describe("CommandV2", () => { it.effect("applies command transforms and preserves later overrides", () => Effect.gen(function* () { const command = yield* CommandV2.Service - const transform = yield* command.transform() - yield* transform((editor) => { + yield* command.transform((editor) => { editor.update("review", (command) => { command.template = "First" command.description = "Review code" diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index 79e872f74e29..ea553671bda9 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -10,6 +10,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" +import { agentHost, host } from "../plugin/host" const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer)) const decode = Schema.decodeUnknownSync(Config.Info) @@ -19,9 +20,7 @@ describe("ConfigAgentPlugin.Plugin", () => { Effect.gen(function* () { const agents = yield* AgentV2.Service const build = AgentV2.ID.make("build") - const defaults = yield* agents.transform() - - yield* defaults((editor) => + yield* agents.transform((editor) => editor.update(build, (agent) => { agent.mode = "primary" agent.permissions.push({ action: "bash", resource: "*", effect: "allow" }) @@ -68,9 +67,8 @@ describe("ConfigAgentPlugin.Plugin", () => { ]), }) - yield* ConfigAgentPlugin.Plugin.effect.pipe( + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(AgentV2.Service, agents), ) const buildAgent = yield* agents.get(build) @@ -150,9 +148,8 @@ describe("ConfigAgentPlugin.Plugin", () => { ]), }) - yield* ConfigAgentPlugin.Plugin.effect.pipe( + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(AgentV2.Service, agents), ) const reviewer = yield* agents.get(AgentV2.ID.make("reviewer")) @@ -177,8 +174,7 @@ describe("ConfigAgentPlugin.Plugin", () => { Effect.gen(function* () { const agents = yield* AgentV2.Service const build = AgentV2.ID.make("build") - const defaults = yield* agents.transform() - yield* defaults((editor) => editor.update(build, () => {})) + yield* agents.transform((editor) => editor.update(build, () => {})) const config = Config.Service.of({ entries: () => @@ -190,9 +186,8 @@ describe("ConfigAgentPlugin.Plugin", () => { ]), }) - yield* ConfigAgentPlugin.Plugin.effect.pipe( + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(AgentV2.Service, agents), ) expect(yield* agents.get(build)).toBeUndefined() @@ -251,9 +246,8 @@ Use native v2 fields.`, ]), }) - yield* ConfigAgentPlugin.Plugin.effect.pipe( + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(AgentV2.Service, agents), ) expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({ diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index da3bb749b456..bc84d9cdb58c 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -11,6 +11,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" +import { host } from "../plugin/host" const it = testEffect(Layer.mergeAll(CommandV2.locationLayer, FSUtil.defaultLayer)) const decode = Schema.decodeUnknownSync(Config.Info) @@ -41,8 +42,7 @@ Review files`, }) const command = yield* CommandV2.Service - yield* ConfigCommandPlugin.Plugin.effect.pipe( - Effect.provideService(CommandV2.Service, command), + yield* ConfigCommandPlugin.Plugin.effect(host({ command })).pipe( Effect.provideService( Config.Service, Config.Service.of({ diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index a2ecc9954b3e..3b10b7a7054f 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -8,6 +8,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderV2 } from "@opencode-ai/core/provider" import { it, withEnv } from "../plugin/provider-helper" +import { catalogHost, host, integrationHost } from "../plugin/host" function request(headers: Record, variant?: string) { return { @@ -58,10 +59,10 @@ describe("ConfigProviderPlugin.Plugin", () => { yield* plugin.add({ ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect.pipe( + effect: ConfigProviderPlugin.Plugin.effect( + host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), + ).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(Integration.Service, integrations), ), }) @@ -119,10 +120,10 @@ describe("ConfigProviderPlugin.Plugin", () => { yield* plugin.add({ ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect.pipe( + effect: ConfigProviderPlugin.Plugin.effect( + host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), + ).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(Integration.Service, integrations), ), }) @@ -222,10 +223,10 @@ describe("ConfigProviderPlugin.Plugin", () => { yield* plugin.add({ ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect.pipe( + effect: ConfigProviderPlugin.Plugin.effect( + host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), + ).pipe( Effect.provideService(Config.Service, config), - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(Integration.Service, integrations), ), }) diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 52b9c0bb6662..2f86714bb2a2 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -9,6 +9,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" +import { host } from "../plugin/host" const it = testEffect(Layer.empty) const decode = Schema.decodeUnknownSync(Config.Info) @@ -18,16 +19,33 @@ describe("ConfigSkillPlugin.Plugin", () => { Effect.gen(function* () { const directory = AbsolutePath.make("/repo/packages/app") const sources: SkillV2.Source[] = [] - const transform = Effect.fnUntraced(function* () { - return Effect.fnUntraced(function* (update: (editor: SkillV2.Editor) => void) { - update({ - source: (source) => sources.push(source), - list: () => sources, - }) + const transform = Effect.fnUntraced(function* (update: (draft: SkillV2.Draft) => void | Effect.Effect) { + const result = update({ + source: (source) => { + sources.push(source) + }, + list: () => sources, }) + if (Effect.isEffect(result)) yield* result + const dispose = Effect.sync(() => { + sources.length = 0 + }) + yield* Effect.addFinalizer(() => dispose) + return { dispose } }) - yield* ConfigSkillPlugin.Plugin.effect.pipe( + yield* ConfigSkillPlugin.Plugin.effect( + host({ + location: location({ directory }), + path: { ...host().path, home: "/home/test" }, + skill: SkillV2.Service.of({ + transform, + rebuild: () => Effect.void, + sources: () => Effect.succeed(sources), + list: () => Effect.succeed([]), + }), + }), + ).pipe( Effect.provideService( Config.Service, Config.Service.of({ @@ -43,16 +61,6 @@ describe("ConfigSkillPlugin.Plugin", () => { ]), }), ), - Effect.provideService(Global.Service, Global.Service.of(Global.make({ home: "/home/test" }))), - Effect.provideService(Location.Service, Location.Service.of(location({ directory }))), - Effect.provideService( - SkillV2.Service, - SkillV2.Service.of({ - transform, - sources: () => Effect.succeed(sources), - list: () => Effect.succeed([]), - }), - ), ) expect(sources).toEqual([ diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index ca4362c60584..ac9cd33e8d13 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -51,7 +51,7 @@ describe("Integration", () => { const openai = Integration.ID.make("openai") yield* integrations - .update((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI"))) + .transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI"))) .pipe(Scope.provide(scope)) expect(yield* integrations.get(openai)).toEqual( new Integration.Info({ id: openai, name: "OpenAI", methods: [], connections: [] }), @@ -70,10 +70,10 @@ describe("Integration", () => { const second = yield* Scope.fork(yield* Scope.Scope) yield* integrations - .update((editor) => editor.update(id, (integration) => (integration.name = "OpenAI"))) + .transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI"))) .pipe(Scope.provide(first)) yield* integrations - .update((editor) => editor.update(id, (integration) => (integration.name = "OpenAI Override"))) + .transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI Override"))) .pipe(Scope.provide(second)) expect((yield* integrations.get(id))?.name).toBe("OpenAI Override") @@ -99,7 +99,7 @@ describe("Integration", () => { }) yield* integrations - .update((editor) => + .transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -108,7 +108,7 @@ describe("Integration", () => { ) .pipe(Scope.provide(first)) yield* integrations - .update((editor) => { + .transform((editor) => { expect(editor.get(integrationID)).toEqual({ id: integrationID, name: "openai" }) expect(editor.list()).toEqual([{ id: integrationID, name: "openai" }]) expect(editor.method.list(integrationID)).toEqual([ @@ -141,7 +141,7 @@ describe("Integration", () => { const integrations = yield* Integration.Service const events = yield* EventV2.Service const integrationID = Integration.ID.make("openai") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { type: "key", label: "API key" }, @@ -179,7 +179,7 @@ describe("Integration", () => { const integrations = yield* Integration.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -238,7 +238,7 @@ describe("Integration", () => { const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") let closed = false - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "ChatGPT" }, @@ -275,7 +275,7 @@ describe("Integration", () => { const integrations = yield* Integration.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("browser") - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "Browser" }, @@ -312,7 +312,7 @@ describe("Integration", () => { const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("browser") let closed = false - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "Browser" }, @@ -375,7 +375,7 @@ describe("Integration", () => { () => Effect.gen(function* () { const integrations = yield* Integration.Service - yield* integrations.update((editor) => + yield* integrations.transform((editor) => editor.method.update({ integrationID, method: { diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 69dba2ae0a5d..9e75bbb641c1 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -1,8 +1,10 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Equal, Hash, Layer, Schema } from "effect" +import { Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect" import { Tool } from "@opencode-ai/core/public" +import { define } from "@opencode-ai/plugin/v2/effect" +import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Location } from "@opencode-ai/core/location" @@ -86,8 +88,7 @@ describe("LocationServiceMap", () => { yield* PluginBoot.Service.use((boot) => boot.wait()) yield* Reference.Service const catalog = yield* Catalog.Service - const transform = yield* catalog.transform() - yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) return { providers: yield* catalog.provider.all(), tools: yield* toolDefinitions(yield* ToolRegistry.Service), @@ -135,4 +136,53 @@ describe("LocationServiceMap", () => { ), ), ) + + it.live("installs public plugins into a location", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const boot = yield* PluginBoot.Service + const catalogUpdated = yield* Deferred.make() + const seen: string[] = [] + yield* boot.add( + define({ + id: "reviewer", + effect: (ctx) => + Effect.gen(function* () { + yield* ctx.event.subscribe("catalog.updated").pipe( + Stream.runForEach(() => Deferred.succeed(catalogUpdated, undefined).pipe(Effect.asVoid)), + Effect.forkScoped({ startImmediately: true }), + ) + yield* ctx.agent.transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code" + item.mode = "subagent" + }) + }) + seen.push((yield* ctx.agent.get("reviewer"))?.description ?? "") + yield* ctx.catalog.transform((catalog) => { + catalog.provider.update("public", (provider) => { + provider.name = "Public provider" + }) + }) + }), + }), + ) + + yield* Deferred.await(catalogUpdated) + expect(seen).toEqual(["Reviews code"]) + expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({ + description: "Reviews code", + mode: "subagent", + }) + }).pipe( + Effect.scoped, + Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))), + ), + ), + ), + ) }) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index c149116cd5e5..349d9ab7e484 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -60,7 +60,7 @@ describe("Npm.add", () => { return yield* npm.add(spec) }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) - expect(Option.isSome(entry.entrypoint)).toBe(true) + expect(entry.entrypoint).toBeDefined() }) }) diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index ebe06400e984..2120a9f51ade 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -74,8 +74,7 @@ function setup(rules: PermissionV2.Ruleset = []) { function setRules(rules: PermissionV2.Ruleset) { return Effect.gen(function* () { const agents = yield* AgentV2.Service - const update = yield* agents.transform() - yield* update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("test"), (agent) => { agent.permissions = [...rules] }), @@ -130,7 +129,7 @@ describe("PermissionV2", () => { Effect.gen(function* () { yield* setup([{ action: "read", resource: "*", effect: "allow" }]) const agents = yield* AgentV2.Service - yield* agents.update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.permissions.push({ action: "read", resource: "*", effect: "deny" }) }), @@ -139,7 +138,7 @@ describe("PermissionV2", () => { expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" }) expect(yield* service.ask(assertion({ agent: AgentV2.ID.make("reviewer") }))).toMatchObject({ effect: "deny" }) - yield* agents.update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.permissions = [] }), @@ -187,8 +186,7 @@ describe("PermissionV2", () => { .run() .pipe(Effect.orDie) const agents = yield* AgentV2.Service - const update = yield* agents.transform() - yield* update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.permissions = [{ action: "todowrite", resource: "*", effect: "allow" }] }), @@ -214,7 +212,7 @@ describe("PermissionV2", () => { .run() .pipe(Effect.orDie) const agents = yield* AgentV2.Service - yield* agents.update((editor) => { + yield* agents.transform((editor) => { editor.remove(AgentV2.ID.make("test")) editor.remove(AgentV2.ID.make("build")) }) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index d89d531147c3..69b63683c764 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -18,7 +18,7 @@ const plugins = PluginV2.layer.pipe(Layer.provide(events)) function state() { return State.create({ initial: () => ({ values: [] as string[] }), - editor: (draft) => ({ + draft: (draft) => ({ add: (value: string) => draft.values.push(value), }), }) @@ -34,8 +34,9 @@ describe("PluginV2", () => { yield* plugin.add({ id: PluginV2.ID.make("scoped"), effect: Effect.gen(function* () { - const transform = yield* values.transform() - yield* transform((editor) => editor.add("scoped")) + yield* values.transform((editor) => { + editor.add("scoped") + }) }), }) expect(values.get().values).toEqual(["scoped"]) @@ -58,8 +59,9 @@ describe("PluginV2", () => { .add({ id, effect: Effect.gen(function* () { - const transform = yield* values.transform() - yield* transform((editor) => editor.add("first")) + yield* values.transform((editor) => { + editor.add("first") + }) yield* Deferred.succeed(firstStarted, undefined) yield* Deferred.await(releaseFirst) }), @@ -71,8 +73,9 @@ describe("PluginV2", () => { .add({ id, effect: Effect.gen(function* () { - const transform = yield* values.transform() - yield* transform((editor) => editor.add("second")) + yield* values.transform((editor) => { + editor.add("second") + }) }), }) .pipe(Effect.forkChild({ startImmediately: true })) diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index 099e18251851..d9d68e98b187 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -6,6 +6,7 @@ import { CommandPlugin } from "@opencode-ai/core/plugin/command" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" +import { host } from "./host" const directory = AbsolutePath.make("/repo/packages/app") const project = AbsolutePath.make("/repo") @@ -21,12 +22,11 @@ describe("CommandPlugin.Plugin", () => { it.effect("registers built-in init and review commands", () => Effect.gen(function* () { const command = yield* CommandV2.Service - yield* CommandPlugin.Plugin.effect.pipe( - Effect.provideService(CommandV2.Service, command), - Effect.provideService( - Location.Service, - Location.Service.of(location({ directory }, { projectDirectory: project })), - ), + yield* CommandPlugin.Plugin.effect( + host({ + command, + location: location({ directory }, { projectDirectory: project }), + }), ) expect(yield* command.get("init")).toMatchObject({ diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts new file mode 100644 index 000000000000..90e71a5117d5 --- /dev/null +++ b/packages/core/test/plugin/host.ts @@ -0,0 +1,291 @@ +import type { PluginHost } from "@opencode-ai/plugin/v2/effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" +import { Integration } from "@opencode-ai/core/integration" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types" +import { Effect, Stream } from "effect" + +export function host(overrides: Partial = {}): PluginHost { + return { + aisdk: { + hook: () => Effect.die("unused aisdk.hook"), + }, + agent: { + get: () => Effect.die("unused agent.get"), + default: () => Effect.die("unused agent.default"), + list: () => Effect.die("unused agent.list"), + rebuild: () => Effect.die("unused agent.rebuild"), + transform: () => Effect.die("unused agent.transform"), + }, + catalog: { + provider: { + get: () => Effect.die("unused catalog.provider.get"), + list: () => Effect.die("unused catalog.provider.list"), + available: () => Effect.die("unused catalog.provider.available"), + }, + model: { + get: () => Effect.die("unused catalog.model.get"), + list: () => Effect.die("unused catalog.model.list"), + available: () => Effect.die("unused catalog.model.available"), + default: () => Effect.die("unused catalog.model.default"), + small: () => Effect.die("unused catalog.model.small"), + }, + rebuild: () => Effect.die("unused catalog.rebuild"), + transform: () => Effect.die("unused catalog.transform"), + }, + command: { + get: () => Effect.die("unused command.get"), + list: () => Effect.die("unused command.list"), + rebuild: () => Effect.die("unused command.rebuild"), + transform: () => Effect.die("unused command.transform"), + }, + event: { + subscribe: () => Stream.die("unused event.subscribe"), + }, + filesystem: { + read: () => Effect.die("unused filesystem.read"), + list: () => Effect.die("unused filesystem.list"), + find: () => Effect.die("unused filesystem.find"), + glob: () => Effect.die("unused filesystem.glob"), + }, + integration: { + get: () => Effect.die("unused integration.get"), + list: () => Effect.die("unused integration.list"), + rebuild: () => Effect.die("unused integration.rebuild"), + transform: () => Effect.die("unused integration.transform"), + }, + location: { + directory: "/unused/location", + project: { directory: "/unused/project" }, + }, + npm: { + add: () => Effect.die("unused npm.add"), + }, + path: { + home: "/unused/home", + data: "/unused/data", + cache: "/unused/cache", + config: "/unused/config", + state: "/unused/state", + temp: "/unused/temp", + }, + reference: { + list: () => Effect.die("unused reference.list"), + rebuild: () => Effect.die("unused reference.rebuild"), + transform: () => Effect.die("unused reference.transform"), + }, + skill: { + sources: () => Effect.die("unused skill.sources"), + list: () => Effect.die("unused skill.list"), + rebuild: () => Effect.die("unused skill.rebuild"), + transform: () => Effect.die("unused skill.transform"), + }, + ...overrides, + } +} + +export function agentHost(agent: AgentV2.Interface): PluginHost["agent"] { + return { + ...host().agent, + transform: (callback) => + agent.transform((draft) => + callback({ + list: () => draft.list().map(agentInfo), + get: (id) => { + const value = draft.get(AgentV2.ID.make(id)) + return value && agentInfo(value) + }, + default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)), + update: (id, update) => + draft.update(AgentV2.ID.make(id), (value) => { + const current = agentInfo(value) + update(current) + Object.assign(value, current, { id: AgentV2.ID.make(current.id) }) + }), + remove: (id) => draft.remove(AgentV2.ID.make(id)), + }), + ), + } +} + +export function catalogHost(catalog: Catalog.Interface): PluginHost["catalog"] { + return { + ...host().catalog, + rebuild: catalog.rebuild, + transform: (callback) => + catalog.transform((draft) => + callback({ + provider: { + list: () => + draft.provider.list().map((value) => ({ + provider: providerInfo(value.provider), + models: new Map(Array.from(value.models, ([id, model]) => [id, modelInfo(model)])), + })), + get: (id) => { + const value = draft.provider.get(ProviderV2.ID.make(id)) + return ( + value && { + provider: providerInfo(value.provider), + models: new Map(Array.from(value.models, ([id, model]) => [id, modelInfo(model)])), + } + ) + }, + update: (id, update) => + draft.provider.update(ProviderV2.ID.make(id), (value) => { + const current = providerInfo(value) + update(current) + Object.assign(value, current, { id: ProviderV2.ID.make(current.id) }) + }), + remove: (id) => draft.provider.remove(ProviderV2.ID.make(id)), + }, + model: { + get: (providerID, modelID) => { + const value = draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)) + return value && modelInfo(value) + }, + update: (providerID, modelID, update) => + draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), (value) => { + const current = modelInfo(value) + update(current) + Object.assign(value, current, { + id: ModelV2.ID.make(current.id), + providerID: ProviderV2.ID.make(current.providerID), + family: current.family === undefined ? undefined : ModelV2.Family.make(current.family), + variants: current.variants.map((variant) => ({ + ...variant, + id: ModelV2.VariantID.make(variant.id), + })), + }) + }), + remove: (providerID, modelID) => + draft.model.remove(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + default: { + get: () => { + const value = draft.model.default.get() + return value && { providerID: value.providerID, modelID: value.modelID } + }, + set: (providerID, modelID) => + draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + }, + }, + }), + ), + } +} + +export function integrationHost(integration: Integration.Interface): PluginHost["integration"] { + const info = (value: Integration.Info) => ({ + id: value.id, + name: value.name, + methods: value.methods.map(method), + connections: value.connections.map((item) => ({ ...item })), + }) + return { + get: (id) => integration.get(Integration.ID.make(id)).pipe(Effect.map((value) => value && info(value))), + list: () => integration.list().pipe(Effect.map((items) => items.map(info))), + rebuild: integration.rebuild, + transform: (callback) => + integration.transform((draft) => + callback({ + list: () => draft.list().map((value) => ({ id: value.id, name: value.name })), + get: (id) => { + const value = draft.get(Integration.ID.make(id)) + return value && { id: value.id, name: value.name } + }, + update: (id, update) => draft.update(Integration.ID.make(id), update), + remove: (id) => draft.remove(Integration.ID.make(id)), + method: { + list: (id) => draft.method.list(Integration.ID.make(id)).map(method), + update: (input) => + input.method.type === "env" + ? draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, names: [...input.method.names] }, + }) + : draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: input.method, + }), + remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)), + }, + }), + ), + } +} + +function method(value: Integration.Method) { + if (value.type === "env") return { type: value.type, names: [...value.names] } + if (value.type === "key") return { type: value.type, label: value.label } + return { + type: value.type, + id: value.id, + label: value.label, + prompts: value.prompts?.map((prompt) => { + if (prompt.type === "text") return { ...prompt } + return { ...prompt, options: prompt.options.map((option) => ({ ...option })) } + }), + } +} + +function internalMethod(value: IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod): Integration.Method { + if (value.type === "env") return value + if (value.type === "key") return value + return { + ...value, + id: Integration.MethodID.make(value.id), + } +} + +function agentInfo(value: AgentV2.Info) { + return { + ...value, + model: value.model && { ...value.model }, + request: { headers: { ...value.request.headers }, body: { ...value.request.body } }, + permissions: value.permissions.map((permission) => ({ ...permission })), + } +} + +function providerInfo(value: ProviderV2.MutableInfo) { + return { + ...value, + api: { ...value.api, settings: value.api.settings && { ...value.api.settings } }, + request: { headers: { ...value.request.headers }, body: { ...value.request.body } }, + } +} + +function modelInfo(value: ModelV2.Info | ModelV2.MutableInfo) { + return { + ...value, + api: { ...value.api, settings: value.api.settings && { ...value.api.settings } }, + capabilities: { + ...value.capabilities, + input: [...value.capabilities.input], + output: [...value.capabilities.output], + }, + request: { + ...value.request, + headers: { ...value.request.headers }, + body: { ...value.request.body }, + generation: value.request.generation && { + ...value.request.generation, + stop: value.request.generation.stop && [...value.request.generation.stop], + }, + options: value.request.options && { ...value.request.options }, + }, + variants: value.variants.map((variant) => ({ + ...variant, + headers: { ...variant.headers }, + body: { ...variant.body }, + generation: variant.generation && { + ...variant.generation, + stop: variant.generation.stop && [...variant.generation.stop], + }, + options: variant.options && { ...variant.options }, + })), + time: { ...value.time }, + cost: value.cost.map((cost) => ({ ...cost, tier: cost.tier && { ...cost.tier }, cache: { ...cost.cache } })), + limit: { ...value.limit }, + } +} diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 236985dac1cf..6b3e153c3cef 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -1,6 +1,6 @@ import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect, Layer, Stream } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" @@ -15,6 +15,7 @@ import { Policy } from "@opencode-ai/core/policy" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" +import { catalogHost, host, integrationHost } from "./host" const events = EventV2.defaultLayer const locationLayer = Layer.succeed( @@ -56,8 +57,15 @@ describe("ModelsDevPlugin", () => { }), () => Effect.gen(function* () { - yield* ModelsDevPlugin.effect const integrations = yield* Integration.Service + const catalog = yield* Catalog.Service + yield* ModelsDevPlugin.effect( + host({ + catalog: catalogHost(catalog), + event: { subscribe: () => Stream.never }, + integration: integrationHost(integrations), + }), + ) expect(yield* integrations.list()).toEqual([ new Integration.Info({ id: Integration.ID.make("acme"), diff --git a/packages/core/test/plugin/provider-alibaba.test.ts b/packages/core/test/plugin/provider-alibaba.test.ts index e2fbb8061a35..017f60fff30e 100644 --- a/packages/core/test/plugin/provider-alibaba.test.ts +++ b/packages/core/test/plugin/provider-alibaba.test.ts @@ -4,13 +4,13 @@ import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba" -import { it, model } from "./provider-helper" +import { addPlugin, it, model } from "./provider-helper" describe("AlibabaPlugin", () => { it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AlibabaPlugin) + yield* addPlugin(plugin, AlibabaPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("alibaba", "qwen"), package: "@ai-sdk/alibaba", options: { name: "alibaba" } }, @@ -23,7 +23,7 @@ describe("AlibabaPlugin", () => { it.effect("ignores non-Alibaba SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AlibabaPlugin) + yield* addPlugin(plugin, AlibabaPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("alibaba", "qwen"), package: "@ai-sdk/openai-compatible", options: { name: "alibaba" } }, @@ -36,7 +36,7 @@ describe("AlibabaPlugin", () => { it.effect("matches the old bundled Alibaba SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AlibabaPlugin) + yield* addPlugin(plugin, AlibabaPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -56,7 +56,7 @@ describe("AlibabaPlugin", () => { it.effect("uses the old default languageModel(api.id) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AlibabaPlugin) + yield* addPlugin(plugin, AlibabaPlugin) const item = model("alibaba", "alias", { api: { id: ModelV2.ID.make("qwen-plus") } }) const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {}) const language = result.sdk?.languageModel(item.api.id) diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index e1ae5bd6793c..df5e5b7ee6e9 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -4,7 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID) @@ -30,9 +30,8 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AmazonBedrockPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* catalog.transform((catalog) => { const bedrock = provider("amazon-bedrock", { api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" }, request: { @@ -59,7 +58,7 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -84,7 +83,7 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -118,7 +117,7 @@ describe("AmazonBedrockPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -138,7 +137,7 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -157,7 +156,7 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -176,7 +175,7 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -196,7 +195,7 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const headers: Array = [] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -225,7 +224,7 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const headers: Array = [] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -253,7 +252,7 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -282,7 +281,7 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) yield* plugin.trigger( "aisdk.language", { @@ -312,7 +311,7 @@ describe("AmazonBedrockPlugin", () => { it.effect("ignores other Bedrock provider subpaths", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -341,7 +340,7 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const headers: Array = [] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -372,7 +371,7 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) yield* plugin.trigger( "aisdk.language", { @@ -433,7 +432,7 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) yield* plugin.trigger( "aisdk.language", { @@ -518,7 +517,7 @@ describe("AmazonBedrockPlugin", () => { expected: "au.anthropic.claude-sonnet-4-5", }, ] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) for (const item of cases) { yield* plugin.trigger( "aisdk.language", @@ -538,7 +537,7 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AmazonBedrockPlugin) + yield* addPlugin(plugin, AmazonBedrockPlugin) const result = yield* plugin.trigger( "aisdk.language", { diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index 85881c3e8443..350ffd2eb123 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -4,16 +4,15 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, model, provider } from "./provider-helper" +import { addPlugin, it, model, provider } from "./provider-helper" describe("AnthropicPlugin", () => { it.effect("applies legacy beta headers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AnthropicPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, AnthropicPlugin) + yield* catalog.transform((catalog) => { const item = provider("anthropic", { api: { type: "aisdk", package: "@ai-sdk/anthropic" }, request: { headers: { Existing: "1" }, body: {} }, @@ -34,9 +33,8 @@ describe("AnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AnthropicPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(provider("openai").id, () => {})) + yield* addPlugin(plugin, AnthropicPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(provider("openai").id, () => {})) expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"]).toBeUndefined() }), ) @@ -45,7 +43,7 @@ describe("AnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* plugin.add(AnthropicPlugin) + yield* addPlugin(plugin, AnthropicPlugin) yield* plugin.add({ id: PluginV2.ID.make("anthropic-sdk-inspector"), effect: Effect.succeed({ @@ -72,7 +70,7 @@ describe("AnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* plugin.add(AnthropicPlugin) + yield* addPlugin(plugin, AnthropicPlugin) yield* plugin.add({ id: PluginV2.ID.make("anthropic-sdk-inspector"), effect: Effect.succeed({ diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts index 3101052cf9aa..83960f96ec3a 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -4,7 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" describe("AzureCognitiveServicesPlugin", () => { it.effect("maps the resource env var to the Azure SDK baseURL", () => @@ -12,9 +12,8 @@ describe("AzureCognitiveServicesPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzureCognitiveServicesPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, AzureCognitiveServicesPlugin) + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => { item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } }) @@ -36,9 +35,8 @@ describe("AzureCognitiveServicesPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzureCognitiveServicesPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, AzureCognitiveServicesPlugin) + yield* catalog.transform((catalog) => { const azure = provider("azure-cognitive-services", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, }) @@ -64,7 +62,7 @@ describe("AzureCognitiveServicesPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AzureCognitiveServicesPlugin) + yield* addPlugin(plugin, AzureCognitiveServicesPlugin) yield* plugin.trigger( "aisdk.language", { @@ -82,7 +80,7 @@ describe("AzureCognitiveServicesPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AzureCognitiveServicesPlugin) + yield* addPlugin(plugin, AzureCognitiveServicesPlugin) yield* plugin.trigger( "aisdk.language", { model: model("azure-cognitive-services", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, @@ -103,7 +101,7 @@ describe("AzureCognitiveServicesPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) - yield* plugin.add(AzureCognitiveServicesPlugin) + yield* addPlugin(plugin, AzureCognitiveServicesPlugin) yield* plugin.trigger( "aisdk.language", { diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index c4bdd806c9d0..b191ffb0da5e 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -12,7 +12,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" -import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" const database = Database.layerFromPath(":memory:").pipe(Layer.fresh) const preferences = Credential.layer.pipe(Layer.provide(database)) @@ -37,9 +37,8 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, AzurePlugin) + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.azure, (item) => { item.api = { type: "aisdk", package: "@ai-sdk/azure" } }) @@ -54,9 +53,8 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, AzurePlugin) + yield* catalog.transform((catalog) => { const azure = provider("azure", { api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: "from-config" } }, @@ -91,9 +89,8 @@ describe("AzurePlugin", () => { metadata: { resourceName: "from-account" }, }), }) - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, AzurePlugin) + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.azure, (item) => { item.api = { type: "aisdk", package: "@ai-sdk/azure" } }) @@ -108,9 +105,8 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, AzurePlugin) + yield* catalog.transform((catalog) => { const azure = provider("azure", { api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: "" } }, @@ -130,9 +126,8 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, AzurePlugin) + yield* catalog.transform((catalog) => { const azure = provider("azure", { api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: " " } }, @@ -151,7 +146,7 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AzurePlugin) + yield* addPlugin(plugin, AzurePlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -170,7 +165,7 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(AzurePlugin) + yield* addPlugin(plugin, AzurePlugin) const exit = yield* plugin .trigger( "aisdk.sdk", @@ -187,7 +182,7 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AzurePlugin) + yield* addPlugin(plugin, AzurePlugin) yield* plugin.trigger( "aisdk.language", { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, @@ -201,7 +196,7 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AzurePlugin) + yield* addPlugin(plugin, AzurePlugin) yield* plugin.trigger( "aisdk.language", { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, @@ -215,7 +210,7 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AzurePlugin) + yield* addPlugin(plugin, AzurePlugin) yield* plugin.trigger( "aisdk.language", { @@ -235,7 +230,7 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(AzurePlugin) + yield* addPlugin(plugin, AzurePlugin) yield* plugin.trigger( "aisdk.language", { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, @@ -259,7 +254,7 @@ describe("AzurePlugin", () => { calls.push(`${method}:${id}`) return { modelId: id, provider: method, specificationVersion: "v3" } } - yield* plugin.add(AzurePlugin) + yield* addPlugin(plugin, AzurePlugin) yield* plugin.trigger( "aisdk.language", { diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index aa192274d616..ec62b4a7bdc5 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -4,7 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, model } from "./provider-helper" +import { addPlugin, it, model } from "./provider-helper" const cerebrasOptions: Record[] = [] @@ -23,9 +23,8 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CerebrasPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, CerebrasPlugin) + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => { item.api = { type: "aisdk", package: "@ai-sdk/cerebras" } item.request.headers.Existing = "1" @@ -42,9 +41,8 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CerebrasPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) + yield* addPlugin(plugin, CerebrasPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).request.headers).toEqual({}) }), ) @@ -53,7 +51,7 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(CerebrasPlugin) + yield* addPlugin(plugin, CerebrasPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -72,7 +70,7 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(CerebrasPlugin) + yield* addPlugin(plugin, CerebrasPlugin) yield* plugin.trigger( "aisdk.sdk", { @@ -90,7 +88,7 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(CerebrasPlugin) + yield* addPlugin(plugin, CerebrasPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index 72ad5da33f1a..2332a3ca27d8 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -2,7 +2,7 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { PluginV2 } from "@opencode-ai/core/plugin" import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway" -import { it, model, withEnv } from "./provider-helper" +import { addPlugin, it, model, withEnv } from "./provider-helper" const aiGatewayCalls: Record[] = [] const unifiedCalls: string[] = [] @@ -78,7 +78,7 @@ describe("CloudflareAIGatewayPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -98,7 +98,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* plugin.trigger( "aisdk.sdk", @@ -142,7 +142,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* plugin.trigger( "aisdk.sdk", @@ -171,7 +171,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* plugin.trigger( "aisdk.sdk", @@ -208,7 +208,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* plugin.trigger( "aisdk.sdk", @@ -239,7 +239,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) yield* plugin.trigger( "aisdk.sdk", @@ -261,7 +261,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) const result = yield* plugin.trigger( "aisdk.sdk", @@ -284,7 +284,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) const result = yield* plugin.trigger( "aisdk.sdk", @@ -313,7 +313,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) const result = yield* plugin.trigger( "aisdk.sdk", @@ -336,7 +336,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) const result = yield* plugin.trigger( "aisdk.sdk", @@ -364,7 +364,7 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareAIGatewayPlugin) + yield* addPlugin(plugin, CloudflareAIGatewayPlugin) const result = yield* plugin.trigger( "aisdk.sdk", diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 208ab8710d3e..b8bc4b718d3b 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -13,7 +13,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" -import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper" const database = Database.layerFromPath(":memory:").pipe(Layer.fresh) const preferences = Credential.layer.pipe(Layer.provide(database)) @@ -57,9 +57,8 @@ describe("CloudflareWorkersAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.api = { type: "aisdk", package: "test-provider" } }), @@ -89,9 +88,8 @@ describe("CloudflareWorkersAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" } }), @@ -109,7 +107,7 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareWorkersAIPlugin) + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -145,9 +143,8 @@ describe("CloudflareWorkersAIPlugin", () => { metadata: { accountId: "account-acct" }, }), }) - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.api = { type: "aisdk", package: "test-provider" } }), @@ -167,9 +164,8 @@ describe("CloudflareWorkersAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.api = { type: "aisdk", package: "test-provider" } provider.request.body.accountId = "configured-acct" @@ -188,7 +184,7 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareWorkersAIPlugin) + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -217,7 +213,7 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareWorkersAIPlugin) + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -247,7 +243,7 @@ describe("CloudflareWorkersAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(CloudflareWorkersAIPlugin) + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) const result = yield* plugin.trigger( "aisdk.language", { @@ -266,7 +262,7 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CloudflareWorkersAIPlugin) + yield* addPlugin(plugin, CloudflareWorkersAIPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { diff --git a/packages/core/test/plugin/provider-cohere.test.ts b/packages/core/test/plugin/provider-cohere.test.ts index a646c3eb6ced..c653f65a014a 100644 --- a/packages/core/test/plugin/provider-cohere.test.ts +++ b/packages/core/test/plugin/provider-cohere.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" const cohereOptions: Record[] = [] @@ -24,7 +24,7 @@ describe("CoherePlugin", () => { it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CoherePlugin) + yield* addPlugin(plugin, CoherePlugin) const ignored = yield* plugin.trigger( "aisdk.sdk", @@ -45,7 +45,7 @@ describe("CoherePlugin", () => { it.effect("uses the model provider ID as the bundled SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(CoherePlugin) + yield* addPlugin(plugin, CoherePlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -70,7 +70,7 @@ describe("CoherePlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) - yield* plugin.add(CoherePlugin) + yield* addPlugin(plugin, CoherePlugin) const result = yield* plugin.trigger( "aisdk.language", { model: model("cohere", "alias", { api: { id: ModelV2.ID.make("command-r-plus") } }), sdk, options: {} }, diff --git a/packages/core/test/plugin/provider-deepinfra.test.ts b/packages/core/test/plugin/provider-deepinfra.test.ts index 43db117a9087..7e2b6322f193 100644 --- a/packages/core/test/plugin/provider-deepinfra.test.ts +++ b/packages/core/test/plugin/provider-deepinfra.test.ts @@ -5,7 +5,7 @@ import { EventV2 } from "@opencode-ai/core/event" import { PluginV2 } from "@opencode-ai/core/plugin" import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra" import { testEffect } from "../lib/effect" -import { it, model } from "./provider-helper" +import { addPlugin, it, model } from "./provider-helper" const itAISDK = testEffect( Layer.provideMerge(AISDK.layer, PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))), @@ -36,7 +36,7 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* plugin.add(DeepInfraPlugin) + yield* addPlugin(plugin, DeepInfraPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } }, @@ -50,7 +50,7 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* plugin.add(DeepInfraPlugin) + yield* addPlugin(plugin, DeepInfraPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -69,7 +69,7 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* plugin.add(DeepInfraPlugin) + yield* addPlugin(plugin, DeepInfraPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -88,7 +88,7 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* plugin.add(DeepInfraPlugin) + yield* addPlugin(plugin, DeepInfraPlugin) const packages = [ "unmatched-package", "@ai-sdk/deepinfra-compatible", @@ -119,7 +119,7 @@ describe("DeepInfraPlugin", () => { resetDeepInfraMock() const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(DeepInfraPlugin) + yield* addPlugin(plugin, DeepInfraPlugin) const language = yield* aisdk.language( model("deepinfra", "meta-llama/Llama-3.3-70B-Instruct", { api: { type: "aisdk", package: "@ai-sdk/deepinfra" }, diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index 2b0be314ba95..ab4a61d656b8 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/packages/core/test/plugin/provider-dynamic.test.ts @@ -11,6 +11,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic" import { testEffect } from "../lib/effect" +import { host } from "./host" import { fixtureProvider, it, model, npmLayer } from "./provider-helper" const fixtureProviderPath = fileURLToPath(fixtureProvider) @@ -18,7 +19,7 @@ const itWithAISDK = testEffect( AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), ) -function npmEntrypointLayer(entrypoint: Option.Option) { +function npmEntrypointLayer(entrypoint?: string) { return Layer.succeed( Npm.Service, Npm.Service.of({ @@ -30,7 +31,12 @@ function npmEntrypointLayer(entrypoint: Option.Option) { } function dynamicPlugin(layer = npmLayer) { - return { id: DynamicProviderPlugin.id, effect: DynamicProviderPlugin.effect.pipe(Effect.provide(layer)) } + return { + id: DynamicProviderPlugin.id, + effect: Effect.gen(function* () { + yield* DynamicProviderPlugin.effect(host({ npm: yield* Npm.Service })) + }).pipe(Effect.provide(layer)), + } } function tempEntrypoint(source: string) { @@ -102,7 +108,7 @@ describe("DynamicProviderPlugin", () => { it.effect("loads npm packages through their resolved import entrypoint", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.some(fixtureProviderPath)))) + yield* plugin.add(dynamicPlugin(npmEntrypointLayer(fixtureProviderPath))) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -120,7 +126,7 @@ describe("DynamicProviderPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.none()))) + yield* plugin.add(dynamicPlugin(npmEntrypointLayer())) const exit = yield* aisdk .language(model("missing-entrypoint", "alias", { api: { type: "aisdk", package: "fixture-provider" } })) .pipe(Effect.exit) @@ -149,7 +155,7 @@ describe("DynamicProviderPlugin", () => { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n") - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.some(tmp.entrypoint)))) + yield* plugin.add(dynamicPlugin(npmEntrypointLayer(tmp.entrypoint))) const exit = yield* aisdk .language(model("missing-factory", "alias", { api: { type: "aisdk", package: "fixture-provider" } })) .pipe(Effect.exit) diff --git a/packages/core/test/plugin/provider-gateway.test.ts b/packages/core/test/plugin/provider-gateway.test.ts index 8ee69b7dd49a..6627d185a58d 100644 --- a/packages/core/test/plugin/provider-gateway.test.ts +++ b/packages/core/test/plugin/provider-gateway.test.ts @@ -2,7 +2,7 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { PluginV2 } from "@opencode-ai/core/plugin" import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway" -import { it, model } from "./provider-helper" +import { addPlugin, it, model } from "./provider-helper" const gatewayCalls: Record[] = [] const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"] @@ -27,7 +27,7 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GatewayPlugin) + yield* addPlugin(plugin, GatewayPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("gateway", "model"), package: "@ai-sdk/gateway", options: { name: "gateway" } }, @@ -42,7 +42,7 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GatewayPlugin) + yield* addPlugin(plugin, GatewayPlugin) const result = yield* plugin.trigger( "aisdk.sdk", @@ -63,7 +63,7 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GatewayPlugin) + yield* addPlugin(plugin, GatewayPlugin) for (const modelID of vercelGatewayModels) { const ignored = yield* plugin.trigger( diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index f16b177e698b..e58e5115b1dd 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -5,13 +5,13 @@ import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" describe("GithubCopilotPlugin", () => { it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GithubCopilotPlugin) + yield* addPlugin(plugin, GithubCopilotPlugin) const ignored = yield* plugin.trigger( "aisdk.sdk", { @@ -39,7 +39,7 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) + yield* addPlugin(plugin, GithubCopilotPlugin) yield* plugin.trigger( "aisdk.language", { @@ -57,7 +57,7 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) + yield* addPlugin(plugin, GithubCopilotPlugin) yield* plugin.trigger( "aisdk.language", { @@ -75,7 +75,7 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) + yield* addPlugin(plugin, GithubCopilotPlugin) yield* plugin.trigger( "aisdk.language", { model: model("github-copilot", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, @@ -115,7 +115,7 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) + yield* addPlugin(plugin, GithubCopilotPlugin) yield* plugin.trigger( "aisdk.language", { @@ -151,9 +151,8 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GithubCopilotPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, GithubCopilotPlugin) + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) @@ -167,9 +166,8 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GithubCopilotPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, GithubCopilotPlugin) + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) @@ -183,7 +181,7 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(GithubCopilotPlugin) + yield* addPlugin(plugin, GithubCopilotPlugin) const result = yield* plugin.trigger( "aisdk.language", { model: model("openai", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index dab52a1f7fa5..37e0836c9a26 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -12,7 +12,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" -import { it, model, npmLayer, withEnv } from "./provider-helper" +import { addPlugin, it, model, npmLayer, withEnv } from "./provider-helper" const gitlabSDKOptions: Record[] = [] const database = Database.layerFromPath(":memory:").pipe(Layer.fresh) @@ -57,7 +57,7 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GitLabPlugin) + yield* addPlugin(plugin, GitLabPlugin) yield* plugin.trigger( "aisdk.sdk", { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, @@ -90,7 +90,7 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GitLabPlugin) + yield* addPlugin(plugin, GitLabPlugin) yield* plugin.trigger( "aisdk.sdk", { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, @@ -111,7 +111,7 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GitLabPlugin) + yield* addPlugin(plugin, GitLabPlugin) yield* plugin.trigger( "aisdk.sdk", { @@ -152,7 +152,7 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GitLabPlugin) + yield* addPlugin(plugin, GitLabPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("gitlab", "claude"), package: "@ai-sdk/openai", options: { name: "gitlab" } }, @@ -178,9 +178,8 @@ describe("GitLabPlugin", () => { integrationID: Integration.ID.make("gitlab"), value: new Credential.Key({ type: "key", key: "account-token" }), }) - yield* plugin.add(GitLabPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) + yield* addPlugin(plugin, GitLabPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) yield* plugin.trigger( "aisdk.sdk", @@ -217,9 +216,8 @@ describe("GitLabPlugin", () => { expires: 9999999999999, }), }) - yield* plugin.add(GitLabPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) + yield* addPlugin(plugin, GitLabPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) yield* plugin.trigger( "aisdk.sdk", @@ -239,7 +237,7 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* plugin.add(GitLabPlugin) + yield* addPlugin(plugin, GitLabPlugin) const result = yield* plugin.trigger( "aisdk.language", { @@ -275,7 +273,7 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* plugin.add(GitLabPlugin) + yield* addPlugin(plugin, GitLabPlugin) const result = yield* plugin.trigger( "aisdk.language", { @@ -302,7 +300,7 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* plugin.add(GitLabPlugin) + yield* addPlugin(plugin, GitLabPlugin) yield* plugin.trigger( "aisdk.language", { @@ -331,7 +329,7 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* plugin.add(GitLabPlugin) + yield* addPlugin(plugin, GitLabPlugin) yield* plugin.trigger( "aisdk.language", { diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts index bdb6029487cf..81c55dba73a8 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -4,7 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model, withEnv } from "./provider-helper" describe("GoogleVertexAnthropicPlugin", () => { it.effect("resolves legacy project and location env on provider update", () => @@ -21,9 +21,8 @@ describe("GoogleVertexAnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } }), @@ -40,9 +39,8 @@ describe("GoogleVertexAnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } provider.request.body.project = "configured-project" @@ -69,7 +67,7 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -92,7 +90,7 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -112,7 +110,7 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -131,7 +129,7 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("keeps configured baseURL for google-vertex Anthropic models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexAnthropicPlugin) + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -148,8 +146,8 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("selects google-vertex Anthropic language models through V2 plugins", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexPlugin) - yield* plugin.add(GoogleVertexAnthropicPlugin) + yield* addPlugin(plugin, GoogleVertexPlugin) + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) const sdkResult = yield* plugin.trigger( "aisdk.sdk", { @@ -180,7 +178,7 @@ describe("GoogleVertexAnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(GoogleVertexAnthropicPlugin) + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) yield* plugin.trigger( "aisdk.language", { @@ -198,7 +196,7 @@ describe("GoogleVertexAnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(GoogleVertexAnthropicPlugin) + yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.language", { diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index cb23cc452cd5..31365371a68f 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -4,7 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model, withEnv } from "./provider-helper" const vertexOptions: Record[] = [] const googleAuthOptions: Record[] = [] @@ -39,9 +39,8 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, GoogleVertexPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.opencode, (provider) => { provider.api = { type: "aisdk", @@ -70,9 +69,8 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, GoogleVertexPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", @@ -109,9 +107,8 @@ describe("GoogleVertexPlugin", () => { vertexOptions.length = 0 const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, GoogleVertexPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", @@ -159,9 +156,8 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, GoogleVertexPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", @@ -188,9 +184,8 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, GoogleVertexPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", @@ -224,9 +219,8 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(GoogleVertexPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => + yield* addPlugin(plugin, GoogleVertexPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" } provider.request.body.project = "config-project" @@ -249,7 +243,7 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { vertexOptions.length = 0 const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexPlugin) + yield* addPlugin(plugin, GoogleVertexPlugin) yield* plugin.trigger( "aisdk.sdk", { @@ -274,7 +268,7 @@ describe("GoogleVertexPlugin", () => { googleAuthOptions.length = 0 const fetchCalls: { input: Parameters[0]; init?: RequestInit }[] = [] const plugin = yield* PluginV2.Service - yield* plugin.add(GoogleVertexPlugin) + yield* addPlugin(plugin, GoogleVertexPlugin) yield* plugin.add({ id: PluginV2.ID.make("capture-openai-compatible"), effect: Effect.succeed({ @@ -328,7 +322,7 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(GoogleVertexPlugin) + yield* addPlugin(plugin, GoogleVertexPlugin) yield* plugin.trigger( "aisdk.language", { diff --git a/packages/core/test/plugin/provider-google.test.ts b/packages/core/test/plugin/provider-google.test.ts index 9880ff3ae58a..c1fab4201e82 100644 --- a/packages/core/test/plugin/provider-google.test.ts +++ b/packages/core/test/plugin/provider-google.test.ts @@ -6,7 +6,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google" import { testEffect } from "../lib/effect" -import { it, model } from "./provider-helper" +import { addPlugin, it, model } from "./provider-helper" const itWithAISDK = testEffect( AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), @@ -16,7 +16,7 @@ describe("GooglePlugin", () => { it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GooglePlugin) + yield* addPlugin(plugin, GooglePlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -34,7 +34,7 @@ describe("GooglePlugin", () => { it.effect("ignores non-Google SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GooglePlugin) + yield* addPlugin(plugin, GooglePlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("google", "gemini"), package: "@ai-sdk/google-vertex", options: { name: "google" } }, @@ -48,7 +48,7 @@ describe("GooglePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(GooglePlugin) + yield* addPlugin(plugin, GooglePlugin) const language = yield* aisdk.language( model("custom-google", "alias", { api: { diff --git a/packages/core/test/plugin/provider-groq.test.ts b/packages/core/test/plugin/provider-groq.test.ts index c6db66b1cb6c..71eb1eeabdfd 100644 --- a/packages/core/test/plugin/provider-groq.test.ts +++ b/packages/core/test/plugin/provider-groq.test.ts @@ -6,7 +6,7 @@ import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq" -import { it, model } from "./provider-helper" +import { addPlugin, it, model } from "./provider-helper" import { testEffect } from "../lib/effect" const aisdkIt = testEffect( @@ -17,7 +17,7 @@ describe("GroqPlugin", () => { it.effect("creates a Groq SDK for @ai-sdk/groq", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GroqPlugin) + yield* addPlugin(plugin, GroqPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("groq", "llama"), package: "@ai-sdk/groq", options: { name: "groq" } }, @@ -30,7 +30,7 @@ describe("GroqPlugin", () => { it.effect("ignores non-Groq SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GroqPlugin) + yield* addPlugin(plugin, GroqPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("groq", "llama"), package: "@ai-sdk/openai-compatible", options: { name: "groq" } }, @@ -43,7 +43,7 @@ describe("GroqPlugin", () => { it.effect("only matches the bundled @ai-sdk/groq package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GroqPlugin) + yield* addPlugin(plugin, GroqPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("groq", "llama"), package: "@ai-sdk/groq/compat", options: { name: "groq" } }, @@ -56,7 +56,7 @@ describe("GroqPlugin", () => { it.effect("matches the old bundled Groq SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(GroqPlugin) + yield* addPlugin(plugin, GroqPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -79,7 +79,7 @@ describe("GroqPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(GroqPlugin) + yield* addPlugin(plugin, GroqPlugin) const result = yield* aisdk.language( model("groq", "alias", { api: { diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index c15928435bcd..4bcf90ee22d2 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -1,4 +1,5 @@ import { Npm } from "@opencode-ai/core/npm" +import type { Plugin } from "@opencode-ai/plugin/v2/effect" import type { LanguageModelV3 } from "@ai-sdk/provider" import { expect } from "bun:test" import { Effect, Layer, Option } from "effect" @@ -13,6 +14,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" +import { catalogHost, host, integrationHost } from "./host" export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href const locationLayer = Layer.succeed( @@ -23,7 +25,7 @@ const locationLayer = Layer.succeed( export const npmLayer = Layer.succeed( Npm.Service, Npm.Service.of({ - add: () => Effect.succeed({ directory: "", entrypoint: Option.none() }), + add: () => Effect.succeed({ directory: "", entrypoint: undefined }), install: () => Effect.void, which: () => Effect.succeed(Option.none()), }), @@ -32,7 +34,8 @@ export const npmLayer = Layer.succeed( export const catalogLayer = Layer.succeed( Catalog.Service, Catalog.Service.of({ - transform: () => Effect.die("unexpected catalog.transform"), + transform: (_transform) => Effect.die("unexpected catalog.transform"), + rebuild: () => Effect.die("unexpected catalog.rebuild"), provider: { get: () => Effect.die("unexpected provider.get"), all: () => Effect.succeed([]), @@ -73,6 +76,25 @@ export const it = testEffect( ), ) +export function addPlugin(plugin: PluginV2.Interface, definition: Plugin) { + return Effect.gen(function* () { + const catalog = yield* Effect.serviceOption(Catalog.Service) + const integration = yield* Effect.serviceOption(Integration.Service) + const npm = yield* Effect.serviceOption(Npm.Service) + const effect = + typeof definition.effect === "function" + ? definition.effect( + host({ + ...(Option.isSome(catalog) ? { catalog: catalogHost(catalog.value) } : {}), + ...(Option.isSome(integration) ? { integration: integrationHost(integration.value) } : {}), + ...(Option.isSome(npm) ? { npm: npm.value } : {}), + }), + ) + : definition.effect + yield* plugin.add({ id: definition.id, effect }) + }) +} + type ProviderInput = Partial> & { api?: ProviderV2.Api request?: ProviderV2.Request diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index 33da03c0327b..34beea51f542 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -5,7 +5,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, provider } from "./provider-helper" +import { addPlugin, expectPluginRegistered, it, provider } from "./provider-helper" describe("KiloPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => @@ -21,9 +21,8 @@ describe("KiloPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(KiloPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, KiloPlugin) + yield* catalog.transform((catalog) => { const kilo = provider("kilo", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, request: { headers: { Existing: "value" }, body: {} }, @@ -47,9 +46,8 @@ describe("KiloPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(KiloPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, KiloPlugin) + yield* catalog.transform((catalog) => { const item = provider("kilo", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, }) @@ -73,9 +71,8 @@ describe("KiloPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(KiloPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, KiloPlugin) + yield* catalog.transform((catalog) => { const kilo = provider("kilo", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, }) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 39a643e348a0..bdd02a353ff3 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -7,13 +7,17 @@ import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" import { ProviderV2 } from "@opencode-ai/core/provider" import { expectPluginRegistered, it, provider } from "./provider-helper" +import { catalogHost, host, integrationHost } from "./host" describe("LLMGatewayPlugin", () => { const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) { const integrations = yield* Integration.Service + const catalog = yield* Catalog.Service yield* plugin.add({ ...LLMGatewayPlugin, - effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), + effect: LLMGatewayPlugin.effect( + host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), + ), }) }) @@ -32,12 +36,11 @@ describe("LLMGatewayPlugin", () => { const catalog = yield* Catalog.Service yield* add(plugin) const integrations = yield* Integration.Service - yield* integrations.update((editor) => { + yield* integrations.transform((editor) => { editor.update(Integration.ID.make("llmgateway"), () => {}) editor.update(Integration.ID.make("openrouter"), () => {}) }) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { const llmgateway = provider("llmgateway", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, request: { headers: { Existing: "value" }, body: {} }, @@ -63,8 +66,7 @@ describe("LLMGatewayPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* add(plugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { const item = provider("llmgateway", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, }) diff --git a/packages/core/test/plugin/provider-mistral.test.ts b/packages/core/test/plugin/provider-mistral.test.ts index b442d4f4d6c2..ea3b3a670969 100644 --- a/packages/core/test/plugin/provider-mistral.test.ts +++ b/packages/core/test/plugin/provider-mistral.test.ts @@ -3,13 +3,13 @@ import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" describe("MistralPlugin", () => { it.effect("creates a Mistral SDK for @ai-sdk/mistral", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(MistralPlugin) + yield* addPlugin(plugin, MistralPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } }, @@ -22,7 +22,7 @@ describe("MistralPlugin", () => { it.effect("ignores non-Mistral SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(MistralPlugin) + yield* addPlugin(plugin, MistralPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -40,7 +40,7 @@ describe("MistralPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* plugin.add(MistralPlugin) + yield* addPlugin(plugin, MistralPlugin) yield* plugin.add({ id: PluginV2.ID.make("mistral-sdk-inspector"), effect: Effect.succeed({ @@ -64,7 +64,7 @@ describe("MistralPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* plugin.add(MistralPlugin) + yield* addPlugin(plugin, MistralPlugin) yield* plugin.add({ id: PluginV2.ID.make("mistral-sdk-inspector"), effect: Effect.succeed({ @@ -92,7 +92,7 @@ describe("MistralPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) - yield* plugin.add(MistralPlugin) + yield* addPlugin(plugin, MistralPlugin) const result = yield* plugin.trigger( "aisdk.language", { model: model("mistral", "alias", { api: { id: ModelV2.ID.make("mistral-large") } }), sdk, options: {} }, diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index e4f781e54a32..9609e4597206 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -5,7 +5,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, provider } from "./provider-helper" +import { addPlugin, expectPluginRegistered, it, provider } from "./provider-helper" describe("NvidiaPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => @@ -21,9 +21,8 @@ describe("NvidiaPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(NvidiaPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, NvidiaPlugin) + yield* catalog.transform((catalog) => { const nvidia = provider("nvidia", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, request: { headers: { Existing: "value" }, body: {} }, @@ -48,9 +47,8 @@ describe("NvidiaPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(NvidiaPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, NvidiaPlugin) + yield* catalog.transform((catalog) => { const item = provider("nvidia", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, request: { headers: {}, body: {} }, @@ -73,9 +71,8 @@ describe("NvidiaPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(NvidiaPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, NvidiaPlugin) + yield* catalog.transform((catalog) => { const item = provider("nvidia", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, request: { diff --git a/packages/core/test/plugin/provider-openai-compatible.test.ts b/packages/core/test/plugin/provider-openai-compatible.test.ts index e8bf1f7575fc..7e695c89c06a 100644 --- a/packages/core/test/plugin/provider-openai-compatible.test.ts +++ b/packages/core/test/plugin/provider-openai-compatible.test.ts @@ -2,13 +2,13 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { PluginV2 } from "@opencode-ai/core/plugin" import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible" -import { it, model } from "./provider-helper" +import { addPlugin, it, model } from "./provider-helper" describe("OpenAICompatiblePlugin", () => { it.effect("preserves explicit includeUsage false and defaults it to true", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(OpenAICompatiblePlugin) + yield* addPlugin(plugin, OpenAICompatiblePlugin) const defaulted = yield* plugin.trigger( "aisdk.sdk", { model: model("custom", "model"), package: "@ai-sdk/openai-compatible", options: { name: "custom" } }, @@ -31,7 +31,7 @@ describe("OpenAICompatiblePlugin", () => { it.effect("defaults includeUsage for OpenAI-compatible package matches", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(OpenAICompatiblePlugin) + yield* addPlugin(plugin, OpenAICompatiblePlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -49,7 +49,7 @@ describe("OpenAICompatiblePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const observed: string[] = [] - yield* plugin.add(OpenAICompatiblePlugin) + yield* addPlugin(plugin, OpenAICompatiblePlugin) yield* plugin.add({ id: PluginV2.ID.make("inspector"), effect: Effect.succeed({ @@ -85,7 +85,7 @@ describe("OpenAICompatiblePlugin", () => { }), }), }) - yield* plugin.add(OpenAICompatiblePlugin) + yield* addPlugin(plugin, OpenAICompatiblePlugin) const result = yield* plugin.trigger( "aisdk.sdk", { diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index d30b585b961f..781529f18aa5 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -7,11 +7,14 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" import { ProviderV2 } from "@opencode-ai/core/provider" import { fakeSelectorSdk, it, model, provider } from "./provider-helper" +import { host, integrationHost } from "./host" function add(plugin: PluginV2.Interface, integrations: Integration.Interface) { return plugin.add({ - ...OpenAIPlugin, - effect: OpenAIPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), + id: OpenAIPlugin.id, + effect: OpenAIPlugin.effect(host({ integration: integrationHost(integrations) })).pipe( + Effect.provideService(Integration.Service, integrations), + ), }) } @@ -106,8 +109,7 @@ describe("OpenAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* add(plugin, yield* Integration.Service) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { const item = provider("openai", { api: { type: "aisdk", package: "@ai-sdk/openai" } }) catalog.provider.update(item.id, (draft) => { draft.api = item.api @@ -125,8 +127,7 @@ describe("OpenAIPlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* add(plugin, yield* Integration.Service) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { const item = provider("custom-openai") catalog.provider.update(item.id, () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 01cabf358116..5375f4b8e8ec 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Layer, Option } from "effect" +import { Effect, Layer, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" @@ -12,6 +12,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { it, model, provider, withEnv } from "./provider-helper" +import { catalogHost, host, integrationHost } from "./host" const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] const locationLayer = Layer.succeed( @@ -19,9 +20,11 @@ const locationLayer = Layer.succeed( Location.Service.of(location({ directory: AbsolutePath.make("test") })), ) -const pluginWithIntegrations = (integrations: Integration.Interface) => ({ +const pluginWithIntegrations = (catalog: Catalog.Interface, integrations: Integration.Interface) => ({ ...OpencodePlugin, - effect: OpencodePlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), + effect: OpencodePlugin.effect( + host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), + ), }) describe("OpencodePlugin", () => { @@ -30,9 +33,8 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) + yield* catalog.transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const paid = model("opencode", "paid", { cost: cost(1) }) @@ -51,9 +53,8 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) + yield* catalog.transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const free = model("opencode", "free", { cost: cost(0) }) @@ -72,9 +73,8 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) + yield* catalog.transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) }) @@ -93,9 +93,8 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) + yield* catalog.transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const paid = model("opencode", "paid", { cost: cost(1) }) @@ -115,15 +114,14 @@ describe("OpencodePlugin", () => { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service const integrations = yield* Integration.Service - yield* plugin.add(pluginWithIntegrations(integrations)) - yield* integrations.update((editor) => { + yield* plugin.add(pluginWithIntegrations(catalog, integrations)) + yield* integrations.transform((editor) => { editor.method.update({ integrationID: Integration.ID.make("opencode"), method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] }, }) }) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { const item = provider("opencode") catalog.provider.update(item.id, () => {}) const paid = model("opencode", "paid", { cost: cost(1) }) @@ -142,9 +140,8 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) + yield* catalog.transform((catalog) => { const item = provider("opencode", { request: { headers: {}, @@ -170,9 +167,8 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) + yield* catalog.transform((catalog) => { const item = provider("openai") catalog.provider.update(item.id, () => {}) const paid = model("openai", "paid", { cost: cost(1) }) @@ -191,20 +187,19 @@ describe("OpencodePlugin", () => { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.opencode - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [...cost(1, 1)] - model.time.released = DateTime.makeUnsafe(Date.now()) + model.time.released = Date.now() }) catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [...cost(10, 10)] - model.time.released = DateTime.makeUnsafe(Date.now()) + model.time.released = Date.now() }) }) diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index fe8ccb623311..a38bd2e823f5 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -6,7 +6,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, model, provider } from "./provider-helper" +import { addPlugin, expectPluginRegistered, it, model, provider } from "./provider-helper" describe("OpenRouterPlugin", () => { it.effect("is registered so legacy OpenRouter behavior can be applied", () => @@ -22,9 +22,8 @@ describe("OpenRouterPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpenRouterPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, OpenRouterPlugin) + yield* catalog.transform((catalog) => { const openrouter = provider("openrouter", { api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, request: { headers: { Existing: "value" }, body: {} }, @@ -48,7 +47,7 @@ describe("OpenRouterPlugin", () => { it.effect("creates an SDK only for the OpenRouter package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(OpenRouterPlugin) + yield* addPlugin(plugin, OpenRouterPlugin) const ignored = yield* plugin.trigger( "aisdk.sdk", @@ -74,9 +73,8 @@ describe("OpenRouterPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpenRouterPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, OpenRouterPlugin) + yield* catalog.transform((catalog) => { const openrouter = provider("openrouter", { api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, }) @@ -107,9 +105,8 @@ describe("OpenRouterPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpenRouterPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, OpenRouterPlugin) + yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) diff --git a/packages/core/test/plugin/provider-perplexity.test.ts b/packages/core/test/plugin/provider-perplexity.test.ts index 444badd8564e..35498d5e9e81 100644 --- a/packages/core/test/plugin/provider-perplexity.test.ts +++ b/packages/core/test/plugin/provider-perplexity.test.ts @@ -3,13 +3,13 @@ import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" describe("PerplexityPlugin", () => { it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(PerplexityPlugin) + yield* addPlugin(plugin, PerplexityPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } }, @@ -22,7 +22,7 @@ describe("PerplexityPlugin", () => { it.effect("ignores packages that are not the bundled Perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(PerplexityPlugin) + yield* addPlugin(plugin, PerplexityPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -40,7 +40,7 @@ describe("PerplexityPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* plugin.add(PerplexityPlugin) + yield* addPlugin(plugin, PerplexityPlugin) yield* plugin.add({ id: PluginV2.ID.make("perplexity-sdk-inspector"), effect: Effect.succeed({ @@ -63,7 +63,7 @@ describe("PerplexityPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* plugin.add(PerplexityPlugin) + yield* addPlugin(plugin, PerplexityPlugin) yield* plugin.add({ id: PluginV2.ID.make("custom-perplexity-sdk-inspector"), effect: Effect.succeed({ @@ -90,7 +90,7 @@ describe("PerplexityPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(PerplexityPlugin) + yield* addPlugin(plugin, PerplexityPlugin) const result = yield* plugin.trigger( "aisdk.language", { diff --git a/packages/core/test/plugin/provider-sap-ai-core.test.ts b/packages/core/test/plugin/provider-sap-ai-core.test.ts index 565b9280ab95..51103167b562 100644 --- a/packages/core/test/plugin/provider-sap-ai-core.test.ts +++ b/packages/core/test/plugin/provider-sap-ai-core.test.ts @@ -1,10 +1,17 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { PluginV2 } from "@opencode-ai/core/plugin" +import { Npm } from "@opencode-ai/core/npm" import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core" import { fixtureProvider, it, model, npmLayer, withEnv } from "./provider-helper" +import { host } from "./host" -const pluginWithNpm = { id: SapAICorePlugin.id, effect: SapAICorePlugin.effect.pipe(Effect.provide(npmLayer)) } +const pluginWithNpm = { + id: SapAICorePlugin.id, + effect: Effect.gen(function* () { + yield* SapAICorePlugin.effect(host({ npm: yield* Npm.Service })) + }).pipe(Effect.provide(npmLayer)), +} describe("SapAICorePlugin", () => { it.effect("copies serviceKey option into AICORE_SERVICE_KEY but keeps SDK options to deployment metadata", () => diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index ff5ec4ba4512..5de7ae06588e 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { PluginV2 } from "@opencode-ai/core/plugin" import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" -import { expectPluginRegistered, it, model, withEnv } from "./provider-helper" +import { addPlugin, expectPluginRegistered, it, model, withEnv } from "./provider-helper" describe("SnowflakeCortexPlugin", () => { it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () => @@ -20,7 +20,7 @@ describe("SnowflakeCortexPlugin", () => { it.effect("ignores non-snowflake-cortex providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) + yield* addPlugin(plugin, SnowflakeCortexPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("openai", "gpt-4"), package: "@ai-sdk/openai", options: { name: "openai" } }, @@ -34,7 +34,7 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) + yield* addPlugin(plugin, SnowflakeCortexPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -53,7 +53,7 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) + yield* addPlugin(plugin, SnowflakeCortexPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -76,7 +76,7 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) + yield* addPlugin(plugin, SnowflakeCortexPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -95,7 +95,7 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(SnowflakeCortexPlugin) + yield* addPlugin(plugin, SnowflakeCortexPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { @@ -119,7 +119,7 @@ describe("SnowflakeCortexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const captured: Record[] = [] - yield* plugin.add(SnowflakeCortexPlugin) + yield* addPlugin(plugin, SnowflakeCortexPlugin) yield* plugin.add({ id: PluginV2.ID.make("inspector"), effect: Effect.succeed({ diff --git a/packages/core/test/plugin/provider-togetherai.test.ts b/packages/core/test/plugin/provider-togetherai.test.ts index 3457c2ac8224..19757e126eb1 100644 --- a/packages/core/test/plugin/provider-togetherai.test.ts +++ b/packages/core/test/plugin/provider-togetherai.test.ts @@ -2,13 +2,13 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { PluginV2 } from "@opencode-ai/core/plugin" import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" describe("TogetherAIPlugin", () => { it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(TogetherAIPlugin) + yield* addPlugin(plugin, TogetherAIPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } }, @@ -21,7 +21,7 @@ describe("TogetherAIPlugin", () => { it.effect("matches the old bundled provider package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(TogetherAIPlugin) + yield* addPlugin(plugin, TogetherAIPlugin) const ignored = yield* plugin.trigger( "aisdk.sdk", @@ -47,7 +47,7 @@ describe("TogetherAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const observed: string[] = [] - yield* plugin.add(TogetherAIPlugin) + yield* addPlugin(plugin, TogetherAIPlugin) yield* plugin.add({ id: PluginV2.ID.make("inspector"), effect: Effect.succeed({ @@ -76,7 +76,7 @@ describe("TogetherAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(TogetherAIPlugin) + yield* addPlugin(plugin, TogetherAIPlugin) const result = yield* plugin.trigger( "aisdk.language", diff --git a/packages/core/test/plugin/provider-venice.test.ts b/packages/core/test/plugin/provider-venice.test.ts index ff4a922ab1e1..148a30ee46ef 100644 --- a/packages/core/test/plugin/provider-venice.test.ts +++ b/packages/core/test/plugin/provider-venice.test.ts @@ -2,13 +2,13 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { PluginV2 } from "@opencode-ai/core/plugin" import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice" -import { fakeSelectorSdk, it, model } from "./provider-helper" +import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" describe("VenicePlugin", () => { it.effect("creates a Venice SDK for venice-ai-sdk-provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(VenicePlugin) + yield* addPlugin(plugin, VenicePlugin) const result = yield* plugin.trigger( "aisdk.sdk", { model: model("venice", "model"), package: "venice-ai-sdk-provider", options: { name: "venice" } }, @@ -22,7 +22,7 @@ describe("VenicePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const observed: string[] = [] - yield* plugin.add(VenicePlugin) + yield* addPlugin(plugin, VenicePlugin) yield* plugin.add({ id: PluginV2.ID.make("inspector"), effect: Effect.succeed({ @@ -49,7 +49,7 @@ describe("VenicePlugin", () => { it.effect("only handles the bundled venice-ai-sdk-provider package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(VenicePlugin) + yield* addPlugin(plugin, VenicePlugin) const similar = yield* plugin.trigger( "aisdk.sdk", { @@ -73,7 +73,7 @@ describe("VenicePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(VenicePlugin) + yield* addPlugin(plugin, VenicePlugin) const result = yield* plugin.trigger( "aisdk.language", { model: model("venice", "alias"), sdk: fakeSelectorSdk(calls), options: {} }, diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index fe0e599ffb5a..5efdfae0767e 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -4,16 +4,15 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, model, provider } from "./provider-helper" +import { addPlugin, it, model, provider } from "./provider-helper" describe("VercelPlugin", () => { it.effect("applies legacy lower-case referer headers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(VercelPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, VercelPlugin) + yield* catalog.transform((catalog) => { const item = provider("vercel", { api: { type: "aisdk", package: "@ai-sdk/vercel" }, request: { headers: { Existing: "1" }, body: {} }, @@ -35,9 +34,8 @@ describe("VercelPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(VercelPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, VercelPlugin) + yield* catalog.transform((catalog) => { const item = provider("vercel", { api: { type: "aisdk", package: "@ai-sdk/vercel" } }) catalog.provider.update(item.id, (draft) => { draft.api = item.api @@ -53,7 +51,7 @@ describe("VercelPlugin", () => { it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(VercelPlugin) + yield* addPlugin(plugin, VercelPlugin) const event = yield* plugin.trigger( "aisdk.sdk", { model: model("custom-vercel", "v0-1.0-md"), package: "@ai-sdk/vercel", options: { name: "custom-vercel" } }, @@ -68,9 +66,8 @@ describe("VercelPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(VercelPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(provider("gateway").id, () => {})) + yield* addPlugin(plugin, VercelPlugin) + yield* catalog.transform((catalog) => catalog.provider.update(provider("gateway").id, () => {})) expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).request.headers).toEqual({}) }), ) diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index e505f8538a40..4ac5cf34f18e 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -6,7 +6,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai" import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { fakeSelectorSdk } from "./provider-helper" +import { addPlugin, fakeSelectorSdk } from "./provider-helper" const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))) @@ -23,7 +23,7 @@ describe("XAIPlugin", () => { it.effect("creates an xAI SDK only for @ai-sdk/xai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(XAIPlugin) + yield* addPlugin(plugin, XAIPlugin) const ignored = yield* plugin.trigger( "aisdk.sdk", @@ -43,20 +43,18 @@ describe("XAIPlugin", () => { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* plugin.add(XAIPlugin) - yield* plugin.add( - PluginV2.define({ - id: PluginV2.ID.make("xai-sdk-name-observer"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { - if (!evt.sdk) return - providers.push(evt.sdk.responses("grok-4").provider) - }), - } - }), + yield* addPlugin(plugin, XAIPlugin) + yield* plugin.add({ + id: PluginV2.ID.make("xai-sdk-name-observer"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (!evt.sdk) return + providers.push(evt.sdk.responses("grok-4").provider) + }), + } }), - ) + }) yield* plugin.trigger( "aisdk.sdk", @@ -77,7 +75,7 @@ describe("XAIPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(XAIPlugin) + yield* addPlugin(plugin, XAIPlugin) const result = yield* plugin.trigger( "aisdk.language", { @@ -98,7 +96,7 @@ describe("XAIPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* plugin.add(XAIPlugin) + yield* addPlugin(plugin, XAIPlugin) const result = yield* plugin.trigger( "aisdk.language", { diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index 101d652615fc..bc1592ccf307 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -5,7 +5,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, provider } from "./provider-helper" +import { addPlugin, expectPluginRegistered, it, provider } from "./provider-helper" describe("ZenmuxPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => @@ -21,9 +21,8 @@ describe("ZenmuxPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(ZenmuxPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, ZenmuxPlugin) + yield* catalog.transform((catalog) => { const item = provider("zenmux", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, }) @@ -41,9 +40,8 @@ describe("ZenmuxPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(ZenmuxPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, ZenmuxPlugin) + yield* catalog.transform((catalog) => { const item = provider("zenmux", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, request: { headers: { Existing: "value" }, body: {} }, @@ -66,9 +64,8 @@ describe("ZenmuxPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(ZenmuxPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, ZenmuxPlugin) + yield* catalog.transform((catalog) => { const item = provider("zenmux", { api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, request: { @@ -93,9 +90,8 @@ describe("ZenmuxPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(ZenmuxPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { + yield* addPlugin(plugin, ZenmuxPlugin) + yield* catalog.transform((catalog) => { const item = provider("openrouter", { request: { headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 63d028e4ec0e..9fefec820fe3 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -6,6 +6,7 @@ import { SkillPlugin } from "@opencode-ai/core/plugin/skill" import { SkillV2 } from "@opencode-ai/core/skill" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" import { testEffect } from "../lib/effect" +import { host } from "./host" const it = testEffect( SkillV2.layer.pipe( @@ -19,7 +20,7 @@ describe("SkillPlugin.Plugin", () => { it.effect("registers the built-in customize-opencode skill", () => Effect.gen(function* () { const skill = yield* SkillV2.Service - yield* SkillPlugin.Plugin.effect.pipe(Effect.provideService(SkillV2.Service, skill)) + yield* SkillPlugin.Plugin.effect(host({ skill })) expect(yield* skill.list()).toContainEqual( expect.objectContaining({ diff --git a/packages/core/test/reference.test.ts b/packages/core/test/reference.test.ts index dfa8a202a605..a5a94b9d2b3d 100644 --- a/packages/core/test/reference.test.ts +++ b/packages/core/test/reference.test.ts @@ -17,7 +17,6 @@ describe("Reference", () => { Effect.gen(function* () { const references = yield* Reference.Service const scope = yield* Scope.make() - const update = yield* references.transform().pipe(Effect.provideService(Scope.Scope, scope)) const path = AbsolutePath.make("/docs") const source = new Reference.LocalSource({ type: "local", @@ -25,7 +24,7 @@ describe("Reference", () => { description: "Use for API documentation", hidden: true, }) - yield* update((editor) => editor.add("docs", source)) + yield* references.transform((editor) => editor.add("docs", source)).pipe(Scope.provide(scope)) expect(yield* references.list()).toEqual([ new Reference.Info({ name: "docs", path, description: "Use for API documentation", hidden: true, source }), @@ -44,10 +43,9 @@ describe("Reference", () => { it.effect("derives Git paths without exposing cache operations", () => Effect.gen(function* () { const references = yield* Reference.Service - const update = yield* references.transform() const repository = Repository.parseRemote("owner/repo") const source = new Reference.GitSource({ type: "git", repository: "owner/repo", branch: "main" }) - yield* update((editor) => editor.add("sdk", source)) + yield* references.transform((editor) => editor.add("sdk", source)) expect(yield* references.list()).toEqual([ new Reference.Info({ @@ -68,14 +66,13 @@ describe("Reference", () => { it.effect("preserves configured Git descriptions", () => Effect.gen(function* () { const references = yield* Reference.Service - const update = yield* references.transform() const repository = Repository.parseRemote("owner/repo") const source = new Reference.GitSource({ type: "git", repository: "owner/repo", description: "Use for SDK implementation details", }) - yield* update((editor) => editor.add("sdk", source)) + yield* references.transform((editor) => editor.add("sdk", source)) expect(yield* references.list()).toEqual([ new Reference.Info({ diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index e1f5e32b7529..a03ec77f031d 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -36,7 +36,7 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) => options: { store: false, serviceTier: "priority" }, }, variants, - time: { released: DateTime.makeUnsafe(0) }, + time: { released: 0 }, cost: [], status: "active", enabled: true, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index af17de17594a..9f935407efca 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -819,7 +819,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const agent = yield* AgentV2.Service - yield* agent.update((editor) => + yield* agent.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.system = "Build agent instructions" agent.mode = "primary" @@ -840,7 +840,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const agent = yield* AgentV2.Service - yield* agent.update((editor) => { + yield* agent.transform((editor) => { editor.update(AgentV2.ID.make("build"), (agent) => { agent.system = "Build agent instructions" agent.mode = "primary" @@ -868,7 +868,7 @@ describe("SessionRunnerLLM", () => { yield* setup const { db } = yield* Database.Service const agent = yield* AgentV2.Service - yield* agent.update((editor) => + yield* agent.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.system = "Reviewer instructions" agent.mode = "primary" diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index d0e01d0677ed..46ee13d1201f 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -59,8 +59,7 @@ describe("SkillV2", () => { }) const skill = yield* SkillV2.Service - const register = yield* skill.transform() - yield* register((editor) => { + yield* skill.transform((editor) => { editor.source({ type: "directory", path: AbsolutePath.make(first) }) editor.source({ type: "directory", path: AbsolutePath.make(first) }) editor.source({ type: "directory", path: AbsolutePath.make(second) }) @@ -108,15 +107,14 @@ describe("SkillV2", () => { urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)]) const agents = yield* AgentV2.Service - yield* agents.update((editor) => + yield* agents.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" }) }), ) const skill = yield* SkillV2.Service - const register = yield* skill.transform() - yield* register((editor) => editor.source({ type: "url", url: "https://example.test/skills/" })) + yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" })) expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) diff --git a/packages/core/test/state.test.ts b/packages/core/test/state.test.ts index cb795f856827..f85eb9a46417 100644 --- a/packages/core/test/state.test.ts +++ b/packages/core/test/state.test.ts @@ -13,13 +13,16 @@ describe("State", () => { let block = true const state = State.create({ initial: () => ({ values: [] as string[] }), - editor: (draft) => ({ add: (value: string) => draft.values.push(value) }), + draft: (draft) => ({ add: (value: string) => draft.values.push(value) }), finalize: () => block ? Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))) : Effect.void, }) const scope = yield* Scope.make() - const update = yield* state.transform().pipe(Scope.provide(scope)) - const fiber = yield* update((editor) => editor.add("registered")).pipe(Effect.forkChild) + const fiber = yield* state + .transform((editor) => { + editor.add("registered") + }) + .pipe(Scope.provide(scope), Effect.forkChild) yield* Deferred.await(rebuilding) const interruption = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild) block = false @@ -31,4 +34,47 @@ describe("State", () => { expect(state.get().values).toEqual([]) }), ) + + it.effect("runs effectful transforms during every rebuild", () => + Effect.gen(function* () { + let value = "first" + const state = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), + }) + + yield* state.transform((editor) => + Effect.sync(() => { + editor.add(value) + }), + ) + expect(state.get().values).toEqual(["first"]) + + value = "second" + yield* state.rebuild() + expect(state.get().values).toEqual(["second"]) + }), + ) + + it.effect("disposes a transform once and rebuilds remaining state", () => + Effect.gen(function* () { + const state = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), + }) + yield* state.transform((editor) => { + editor.add("first") + }) + const registration = yield* state.transform((editor) => { + editor.add("second") + }) + expect(state.get().values).toEqual(["first", "second"]) + + yield* registration.dispose + expect(state.get().values).toEqual(["first"]) + + yield* registration.dispose + expect(state.get().values).toEqual(["first"]) + }), + ) }) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 8c08454c4f49..3a54be6c0b70 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -46,6 +46,7 @@ describe("SkillTool", () => { const boot = Layer.succeed( PluginBoot.Service, PluginBoot.Service.of({ + add: () => Effect.void, wait: () => Effect.sync(() => { bootWaited = true @@ -69,7 +70,8 @@ describe("SkillTool", () => { const skills = Layer.succeed( SkillV2.Service, SkillV2.Service.of({ - transform: () => Effect.die("unused"), + transform: (_transform) => Effect.die("unused"), + rebuild: () => Effect.die("unused"), sources: () => Effect.die("unused"), list: () => Effect.succeed(current), }), diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 1715550eccc2..09c8134656ba 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -11,12 +11,14 @@ "exports": { ".": "./src/index.ts", "./tool": "./src/tool.ts", - "./tui": "./src/tui.ts" + "./tui": "./src/tui.ts", + "./v2/effect": "./src/v2/effect/index.ts" }, "files": [ "dist" ], "dependencies": { + "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", "zod": "catalog:" diff --git a/packages/plugin/src/v2/effect/PLAN.md b/packages/plugin/src/v2/effect/PLAN.md new file mode 100644 index 000000000000..71fa07bd7b04 --- /dev/null +++ b/packages/plugin/src/v2/effect/PLAN.md @@ -0,0 +1,515 @@ +# V2 Plugin System Implementation Plan + +## Status + +This document describes the agreed target design for the V2 plugin system. It is an implementation plan, not documentation for the current API. + +## Goals + +- Internal and external plugins use the same public plugin API. +- Effect plugins import `@opencode-ai/plugin/v2/effect`, not `@opencode-ai/core`. +- Public domain values use generated `@opencode-ai/sdk` types. +- Core may retain branded IDs, decoded Effect schemas, and internal service types. +- Plugins may register replayable domain transforms and runtime hooks imperatively during setup. +- Registrations are scoped, independently disposable, ordered, and removable. +- Dynamic sources such as models.dev, config files, and skill directories can rebuild one domain without reloading the entire Location. +- The initial implementation covers the Effect API. A Promise API will be designed afterward as a wrapper over the same capabilities. + +## Authoring Model + +A plugin setup effect receives `PluginHost` and imperatively registers transforms and hooks. + +```ts +export const Plugin = define({ + id: "example", + effect: (ctx) => + Effect.gen(function* () { + yield* ctx.agent.transform( + Effect.fn(function* (agent) { + agent.update("reviewer", (item) => { + item.description = "Reviews code for regressions" + item.mode = "subagent" + }) + }), + ) + + yield* ctx.tool.hook( + "execute.before", + Effect.fn(function* (event) { + event.args.update(sanitizeArgs) + }), + ) + }), +}) +``` + +Plugin setup does not return hooks. + +## Public Naming + +Settled names: + +- Replayable domain registration: `transform` +- Explicit domain replay: `rebuild` +- Runtime callback registration: `hook` +- Registration cleanup: `dispose` +- Event domain: singular `event` +- Other domains are singular: `agent`, `command`, `integration`, `reference`, `session`, `skill`, and `tool`; `catalog` remains `catalog` +- Hook names use dotted lifecycle names such as `"execute.before"` and `"execute.after"` + +## Transform API + +Each transformable domain exposes: + +```ts +interface TransformDomain { + transform(callback: (editor: Editor) => Effect.Effect): Effect.Effect + + rebuild(): Effect.Effect +} +``` + +The actual callback may be represented with the project's normal `Effect.fn` style. + +```ts +const registration = + yield * + ctx.catalog.transform( + Effect.fn(function* (catalog) { + const integration = yield* ctx.integration.get("anthropic") + if (!integration) return + + catalog.provider.update("anthropic", (provider) => { + provider.name = "Anthropic" + }) + }), + ) +``` + +Transforms may perform arbitrary Effects, including reads from other PluginHost services, filesystem I/O, and network I/O. Reads from another domain observe that domain's latest committed state. + +Transforms have no typed error channel. Unexpected failures are defects. + +## Transform Semantics + +- Every call to `transform()` creates an independent registration. +- Multiple transforms from one plugin and domain are allowed. +- Transform order is plugin registration order, then transform registration order within the plugin. +- A transform is automatically removed when its registration scope closes. +- `Registration.dispose` removes it early and is idempotent. +- Registering or disposing a transform automatically rebuilds its domain. +- During bulk plugin boot, automatic rebuilds are deferred and each affected domain is rebuilt once after the batch. +- `rebuild()` waits until replay and finalization complete. +- `rebuild()` always replays every active transform for the domain. +- Rebuilds are serialized and coalesced. Calls arriving during an active rebuild schedule at most one additional rebuild. +- A rebuild captures its registration list at the start. Concurrent registration changes affect the next rebuild. +- Transforms may not register or dispose transforms while replaying. Such changes are rejected or deferred by the runtime. +- Calling `rebuild()` for the currently rebuilding domain from one of its transforms is rejected. +- Rebuilding another domain from a transform is deferred until the current transform finishes. + +## Registration API + +Transforms and runtime hooks return the same Effect registration type. + +```ts +interface Registration { + readonly dispose: Effect.Effect +} +``` + +Registration behavior: + +- Automatically attached to the current `Scope.Scope` +- Explicitly disposable before scope closure +- Disposal affects future replays or invocations +- An in-flight rebuild or hook invocation uses the registration snapshot captured when it started and is allowed to finish + +## Runtime Hook API + +Domains expose runtime interception through `hook()`. + +```ts +const registration = + yield * + ctx.tool.hook( + "execute.before", + Effect.fn(function* (event) { + event.args.update(sanitizeArgs) + }), + ) +``` + +Runtime hook behavior: + +- Multiple registrations for the same hook are allowed. +- Hooks run sequentially in plugin and registration order. +- Later hooks observe mutations made by earlier hooks. +- Hook registration is scope-owned and independently disposable. +- Disposal affects future invocations; an in-flight invocation finishes using its captured registration snapshot. +- Runtime hooks are not replayed during domain rebuilds. +- Runtime hook callbacks have no typed error channel. + +## Hook Contexts + +Each hook receives one purpose-built context object rather than separate input/output parameters. + +```ts +ctx.tool.hook("execute.before", (event) => { + event.args.update((args) => ({ + ...args, + timeout: 30, + })) +}) +``` + +Hook context objects may contain: + +- Readonly SDK-typed operation data +- Purpose-built methods for allowed mutations +- Capability methods where the operation requires more than field assignment + +They must not expose core drafts or unrestricted internal objects. + +## Domain Transforms Versus Runtime Hooks + +Both use the same low-level scoped registration registry, but consumers invoke them differently. + +```ts +ctx.tool.transform(...) // replayed to build effective tool registry state +ctx.tool.hook(...) // invoked at a live tool operation boundary +``` + +The shared low-level machinery owns registration order, scope cleanup, disposal, and snapshots. Each domain owns when its transforms or runtime hooks execute. + +## Event API + +The Effect API exposes the existing event system as typed streams using generated SDK event discriminants. + +```ts +ctx.event.subscribe("catalog.updated") +// Stream.Stream +``` + +Example: + +```ts +yield * + ctx.event.subscribe("catalog.updated").pipe( + Stream.runForEach(() => ctx.agent.rebuild()), + Effect.forkScoped, + ) +``` + +The plugin package derives event payload types from the generated SDK `Event` union: + +```ts +type EventMap = { + [Item in Event as Item["type"]]: Item +} +``` + +Core resolves the public event type string to its internal event definition and delegates to `EventV2.Service.subscribe`. + +## Domain State Model + +Each transformable core service continues to own: + +- Base state +- Effective committed state +- Editor creation +- Ordered transform registrations for that domain +- Rebuild serialization and coalescing +- Core finalization +- Commit and post-commit events + +The initial implementation should evolve the existing generic `State` helper rather than create a central cross-domain state manager. + +```text +base state +→ replay active transforms in order +→ core domain finalization +→ commit effective state +→ publish updated event +``` + +No cross-domain transform or transaction API is included. + +## Finalization + +Each domain has one plugin transform phase followed by core finalization. + +Core finalization is for invariants and materialization, not plugin extension behavior. + +Examples: + +- Catalog policy filtering and validation +- Reference repository materialization +- Integration connection projection +- Index construction +- Post-commit update events + +Finalizers should distinguish pre-commit work from post-commit notification. Update events should publish after the new state is visible. + +## Plugin Order + +The default distribution uses an opinionated internal order: + +```text +1. Built-in agents, commands, and skills +2. Base data sources such as models.dev +3. Configuration projections +4. Provider-specific normalization and authentication +5. External user plugins +6. Core domain finalization +``` + +For catalog transforms: + +```text +models.dev +→ config provider overrides +→ built-in provider normalization +→ user catalog transforms +→ catalog finalization +``` + +This replaces the current distinction between setup-installed State transforms and catalog hooks invoked from the catalog finalizer. + +Replacing a plugin with the same ID retains its existing order position. The old plugin is disabled before the replacement setup starts. + +## Boot Batching + +Plugin boot runs in an internal registration batch. + +```text +begin batch +→ initialize plugins sequentially +→ register transforms and hooks +→ collect affected domains +→ rebuild each affected domain once +→ end batch +``` + +Registration itself is not staged per plugin. If setup fails, closing the plugin's child scope removes every registration made before the failure. + +Outside a batch, transform registration and disposal rebuild immediately. + +## Models.dev Example + +Models.dev performs effectful reads directly from its transforms and rebuilds affected domains after refresh. + +```ts +export const ModelsDevPlugin = define({ + id: "models-dev", + effect: (ctx) => + Effect.gen(function* () { + const modelsDev = yield* ModelsDev.Service + const event = yield* EventV2.Service + + yield* ctx.integration.transform( + Effect.fn(function* (integration) { + const data = yield* modelsDev.get() + applyIntegrations(data, integration) + }), + ) + + yield* ctx.catalog.transform( + Effect.fn(function* (catalog) { + const data = yield* modelsDev.get() + applyCatalog(data, catalog) + }), + ) + + yield* event.subscribe(ModelsDev.Event.Refreshed).pipe( + Stream.runForEach( + Effect.fn(function* () { + yield* ctx.integration.rebuild() + yield* ctx.catalog.rebuild() + }), + ), + Effect.forkScoped({ startImmediately: true }), + ) + }), +}) +``` + +The two domains rebuild sequentially. This plan does not add a cross-domain atomic transaction. + +## Config Watcher Example + +```ts +export const ConfigPlugin = define({ + id: "config", + effect: (ctx) => + Effect.gen(function* () { + const config = yield* ConfigSource.Service + + yield* ctx.agent.transform( + Effect.fn(function* (agent) { + applyAgentConfig(yield* config.get(), agent) + }), + ) + + yield* ctx.command.transform( + Effect.fn(function* (command) { + applyCommandConfig(yield* config.get(), command) + }), + ) + + yield* config.changes.pipe( + Stream.runForEach( + Effect.fn(function* () { + yield* ctx.agent.rebuild() + yield* ctx.command.rebuild() + }), + ), + Effect.forkScoped, + ) + }), +}) +``` + +## Cross-Domain Read Example + +A transform may read another committed service. It must still arrange for its own domain to rebuild when that dependency changes. + +```ts +export const AnthropicAgentPlugin = define({ + id: "anthropic-agent", + effect: (ctx) => + Effect.gen(function* () { + yield* ctx.agent.transform( + Effect.fn(function* (agent) { + const providers = yield* ctx.catalog.provider.list() + if (!providers.some((provider) => provider.id === "anthropic")) return + + agent.update("anthropic-reviewer", (item) => { + item.description = "Reviews code using Anthropic" + item.mode = "subagent" + item.model = { + providerID: "anthropic", + id: "claude-sonnet", + } + }) + }), + ) + + yield* ctx.event.subscribe("catalog.updated").pipe( + Stream.runForEach(() => ctx.agent.rebuild()), + Effect.forkScoped, + ) + }), +}) +``` + +The runtime does not infer cross-domain dependencies. + +## Embedding API Compatibility + +The imperative registration model maps naturally to a future application embedding API: + +```ts +const registration = oc.agent.transform((agent) => { + agent.update("reviewer", configureReviewer) +}) + +registration.dispose() +``` + +An application registration is stored as an application-level plugin registration. It attaches to every current Location and is installed during future Location boot. Disposal removes all current attachments and prevents future attachment. + +The Effect implementation remains the canonical runtime. Promise and embedding wrappers are deferred until after the Effect API is stable. + +## Migration Plan + +### 1. Define Public Contracts + +- Define `PluginHost` domain capabilities in `@opencode-ai/plugin/v2/effect`. +- Define SDK-typed editors for agent, catalog, command, integration, reference, skill, and tool. +- Define typed runtime hook maps per domain. +- Define `Registration`. +- Define typed `event.subscribe(type)`. + +### 2. Generalize Registration Machinery + +- Add one low-level scoped registration registry used by transforms and runtime hooks. +- Preserve plugin order and registration order. +- Support idempotent disposal and registration snapshots. +- Retain plugin position during same-ID replacement. + +### 3. Evolve State + +- Replace the current returned transform-slot updater with direct `transform(callback)` registration. +- Support Effectful callbacks. +- Add public `rebuild()`. +- Add rebuild serialization and coalescing. +- Add boot batching that defers automatic rebuilds. +- Move update event publication after commit. + +### 4. Expand Domain Transform Hooks + +- Agent +- Catalog +- Command +- Integration +- Reference +- Skill +- Tool + +### 5. Migrate Existing Plugins + +- Built-in agent transform +- Built-in command transform +- Built-in skill transform +- Models.dev catalog and integration transforms +- Config transforms +- OpenAI integration transform +- Provider catalog transforms + +### 6. Migrate Runtime Hooks + +- AI SDK resolution +- Language model resolution +- Tool execution hooks +- Session prompt/context hooks as required + +### 7. Remove Returned Hooks + +- Remove `HookFunctions` as the plugin setup return value. +- Remove catalog's special finalizer-triggered plugin hook path. +- Remove `plugin.added` catalog mutation handling. +- Make add/remove/replacement rely on scoped registration and domain rebuilds. + +### 8. Add Event Adapter + +- Build the SDK event discriminant map. +- Resolve public type strings to internal EventV2 definitions. +- Return typed Effect streams. + +### 9. Verification + +- Transform order is deterministic. +- Multiple transforms per plugin/domain compose. +- Registration and disposal rebuild automatically outside boot batches. +- Boot performs one rebuild per affected domain. +- Plugin setup failure removes prior registrations. +- Same-ID replacement retains order and disables the old plugin first. +- Rebuilds serialize and coalesce. +- Registration changes during replay affect the next rebuild. +- Same-domain recursive rebuild is rejected. +- Cross-domain rebuild requests from transforms are deferred. +- Hook execution is sequential and snapshot-based. +- Models.dev refresh replays config and provider transforms. +- Config and skill watcher refreshes remove stale entries. +- Plugin removal restores prior effective state. +- Events observe newly committed state. + +## Deferred Decisions + +- Promise API shape +- Typed error model +- Transform timeouts +- Cross-domain atomic rebuilds +- Automatic dependency tracking +- Whole-Location generation reload +- Exact editors and runtime hooks not required by current plugins diff --git a/packages/plugin/src/v2/effect/README.md b/packages/plugin/src/v2/effect/README.md new file mode 100644 index 000000000000..4fbf469d8795 --- /dev/null +++ b/packages/plugin/src/v2/effect/README.md @@ -0,0 +1,585 @@ +# OpenCode V2 Plugin API + +> Design proposal. The API shown here is the intended V2 model and is not fully implemented yet. + +This document explains how OpenCode V2 plugins contribute agents, commands, skills, integrations, providers, and models without importing `@opencode-ai/core`. + +The design has four goals: + +- Internal and external plugins use the same API. +- Plugin values use generated `@opencode-ai/sdk` types. +- Core may keep richer internal representations such as branded IDs and decoded Effect schemas. +- Plugins can react to changing data without reloading an entire Location. + +## Mental Model + +A plugin has two parts: + +1. A setup effect that loads data, starts scoped subscriptions, and returns hooks. +2. Singular transform hooks that describe the plugin's current contribution to a domain. + +```ts +export default defineEffectPlugin({ + id: "example", + effect: (ctx) => + Effect.gen(function* () { + return { + "agent.transform": (agent) => { + // Describe this plugin's agent contribution. + }, + } + }), +}) +``` + +A transform is not a one-time mutation. It is a replayable declaration. + +OpenCode may run it when: + +- The plugin is added. +- The plugin is removed or replaced. +- Another plugin affecting the same domain changes. +- The plugin explicitly invalidates the domain. + +Transforms must therefore be synchronous, deterministic, and safe to rerun. + +## Why Hooks Are Returned + +Each transform is a singular property of the plugin definition: + +```ts +return { + "catalog.transform": applyCatalog, +} +``` + +This makes it structurally clear that one plugin has at most one transform per domain. There is no ambiguous behavior from calling `transform()` multiple times during setup. + +Transforms from different plugins compose in plugin order. + +```text +models.dev catalog transform +→ config catalog transform +→ provider catalog transforms +→ user catalog transforms +→ core catalog finalizer +``` + +## Your First Plugin + +This plugin adds a reviewer agent. + +```ts +import { defineEffectPlugin } from "@opencode-ai/plugin/v2/effect" +import { Effect } from "effect" + +export default defineEffectPlugin({ + id: "reviewer", + effect: () => + Effect.succeed({ + "agent.transform": (agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code for correctness and regressions" + item.system = "Review the requested code. Prioritize bugs and behavioral regressions." + item.mode = "subagent" + item.hidden = false + }) + }, + }), +}) +``` + +The editor supplies a complete default agent when `reviewer` does not exist. The callback modifies that value using the generated SDK agent shape. + +When the plugin unloads, OpenCode rebuilds the agent registry without this transform. The reviewer disappears automatically. + +## Transform Editors + +Editors support ordered reads and writes while a domain is being rebuilt. + +```ts +"agent.transform": (agent) => { + const existing = agent.get("reviewer") + + agent.update("reviewer", (item) => { + item.description ??= existing?.description ?? "Reviews code" + }) +} +``` + +An editor is valid only during the transform call. Do not retain it in plugin state. + +Later plugins see mutations made by earlier plugins in the same rebuild. + +## Adding A Provider And Model + +This plugin contributes one provider and one model. + +```ts +import { defineEffectPlugin } from "@opencode-ai/plugin/v2/effect" +import { Effect } from "effect" + +export default defineEffectPlugin({ + id: "acme", + effect: () => + Effect.succeed({ + "catalog.transform": (catalog) => { + catalog.provider.update("acme", (provider) => { + provider.name = "Acme AI" + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.acme.example/v1", + } + }) + + catalog.model.update("acme", "acme-chat", (model) => { + model.name = "Acme Chat" + model.family = "acme" + model.api = { + id: "acme-chat", + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.acme.example/v1", + } + model.capabilities = { + tools: true, + input: ["text"], + output: ["text"], + } + model.time.released = Date.now() + model.status = "active" + model.enabled = true + model.limit = { + context: 128_000, + output: 16_384, + } + }) + }, + }), +}) +``` + +The provider and model values use generated SDK types. Core may encode and decode richer internal schema values at the plugin boundary. + +## Dynamic Data And Invalidation + +Some plugins depend on data that changes after setup. Examples include: + +- models.dev refreshes +- config file watchers +- skill directory watchers +- authentication state changes + +The plugin keeps the current data in its own scoped state. When that data changes, it invalidates each affected domain. + +```ts +let data = yield * loadData() + +return { + "catalog.transform": (catalog) => { + applyCatalog(data, catalog) + }, +} +``` + +After changing `data`: + +```ts +data = yield * loadData() +yield * ctx.catalog.invalidate() +``` + +Invalidation does not mutate the current catalog in place. It requests a rebuild: + +```text +create fresh catalog state +→ replay every catalog transform in plugin order +→ run the core catalog finalizer +→ commit the new catalog +→ publish catalog.updated +``` + +Repeated invalidations are serialized and may be coalesced. + +## Models.dev Example + +Models.dev is the main example of a dynamic plugin. It projects one changing source into the integration and catalog domains. + +```ts +import { defineEffectPlugin } from "@opencode-ai/plugin/v2/effect" +import { Effect, Stream } from "effect" + +export default defineEffectPlugin({ + id: "models-dev", + effect: (ctx) => + Effect.gen(function* () { + const modelsDev = yield* ModelsDev.Service + const events = yield* EventV2.Service + let data = yield* modelsDev.get() + + yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( + Stream.runForEach( + Effect.fn(function* () { + data = yield* modelsDev.get() + yield* ctx.integration.invalidate() + yield* ctx.catalog.invalidate() + }), + ), + Effect.forkScoped({ startImmediately: true }), + ) + + return { + "integration.transform": (integration) => { + for (const provider of Object.values(data)) { + if (provider.env.length === 0) continue + + integration.update(provider.id, (item) => { + item.name = provider.name + }) + + integration.method.update({ + integrationID: provider.id, + method: { type: "key" }, + }) + + integration.method.update({ + integrationID: provider.id, + method: { + type: "env", + names: [...provider.env], + }, + }) + } + }, + + "catalog.transform": (catalog) => { + for (const provider of Object.values(data)) { + applyProvider(provider, catalog) + } + }, + } + }), +}) +``` + +`ModelsDev.Service` and `ModelsDev.Event` are privileged internal dependencies in this example. The integration and catalog contributions still use the same hooks available to external plugins. + +This design intentionally does not require a special multi-domain transform. The two domains rebuild independently. If strict cross-domain atomic publication becomes a requirement, it should be designed separately rather than making every transform combinatorial. + +## Config File Watching + +A config plugin can project one parsed config snapshot into several independent domains. + +```ts +export default defineEffectPlugin({ + id: "config", + effect: (ctx) => + Effect.gen(function* () { + let config = yield* loadConfig() + + yield* watchConfig.pipe( + Stream.runForEach( + Effect.fn(function* () { + config = yield* loadConfig() + yield* ctx.agent.invalidate() + yield* ctx.command.invalidate() + yield* ctx.catalog.invalidate() + yield* ctx.integration.invalidate() + yield* ctx.reference.invalidate() + yield* ctx.skill.invalidate() + }), + ), + Effect.forkScoped, + ) + + return { + "agent.transform": (agent) => applyAgentConfig(config, agent), + "command.transform": (command) => applyCommandConfig(config, command), + "catalog.transform": (catalog) => applyProviderConfig(config, catalog), + "integration.transform": (integration) => applyIntegrationConfig(config, integration), + "reference.transform": (reference) => applyReferenceConfig(config, reference), + "skill.transform": (skill) => applySkillConfig(config, skill), + } + }), +}) +``` + +The watcher performs I/O. The transforms only project the latest in-memory snapshot. + +## Skill Directory Watching + +A skill plugin follows the same pattern. + +```ts +export default defineEffectPlugin({ + id: "workspace-skills", + effect: (ctx) => + Effect.gen(function* () { + let sources = yield* discoverSkills() + + yield* watchSkillDirectories.pipe( + Stream.runForEach( + Effect.fn(function* () { + sources = yield* discoverSkills() + yield* ctx.skill.invalidate() + }), + ), + Effect.forkScoped, + ) + + return { + "skill.transform": (skill) => { + for (const source of sources) skill.source(source) + }, + } + }), +}) +``` + +Rebuilding the source registry may not be enough if discovered skill contents are cached separately. Domain invalidation must include all materialized state owned by that domain. + +## Runtime Hooks + +Transform hooks build registry state. Runtime hooks intercept live operations. + +```ts +return { + "catalog.transform": (catalog) => { + // Synchronous and replayable. + }, + + "aisdk.sdk": Effect.fn(function* (event) { + // Runs when OpenCode needs an AI SDK provider. + }), + + "aisdk.language": Effect.fn(function* (event) { + // Runs when OpenCode selects a language model implementation. + }), +} +``` + +Runtime hooks may perform Effects appropriate to the operation. Transform hooks must remain replay-safe. + +## Integration Authentication + +Executable registrations may be installed during an integration transform. + +```ts +return { + "integration.transform": (integration) => { + integration.update("openai", (item) => { + item.name = "OpenAI" + }) + + integration.method.update({ + integrationID: "openai", + method: { + id: "chatgpt-browser", + type: "oauth", + label: "ChatGPT Pro/Plus (browser)", + }, + authorize: browserAuthorize, + refresh: refreshCredential, + }) + }, +} +``` + +Replay installs callback values. It must not start OAuth, open a server, or refresh credentials. Those effects run later when core invokes the stored implementation. + +## Reading Other Domains + +A transform may need information from another committed domain. + +```ts +"agent.transform": (agent) => { + if (!anthropicAvailable) return + + agent.update("anthropic-reviewer", (item) => { + item.model = { + providerID: "anthropic", + id: "claude-sonnet", + } + }) +} +``` + +Load or subscribe to the dependency during setup, keep a local snapshot, and invalidate the dependent domain when the snapshot changes. + +```ts +let anthropicAvailable = yield * readAnthropicAvailability() + +yield * + catalogChanges.pipe( + Stream.runForEach( + Effect.fn(function* () { + anthropicAvailable = yield* readAnthropicAvailability() + yield* ctx.agent.invalidate() + }), + ), + Effect.forkScoped, + ) +``` + +This keeps transform callbacks synchronous and avoids hidden dependency tracking. + +## Plugin Order + +OpenCode's default distribution uses an opinionated order. + +```text +1. Built-in agents, commands, and skills +2. Base data sources such as models.dev +3. Configuration projections +4. Provider-specific normalization and authentication +5. External user plugins +6. Core domain finalization +``` + +For the catalog: + +```text +models.dev +→ config provider overrides +→ built-in provider normalization +→ user catalog transforms +→ policy and validation +→ commit +→ catalog.updated +``` + +Ordering is observable behavior. Later transforms see and may override earlier transforms. + +## Core Finalization + +Plugin transforms and core finalization are different concepts. + +Transforms describe configurable plugin contributions. Core finalization enforces domain invariants. + +Catalog finalization may: + +- Validate the materialized catalog. +- Apply provider-use policy. +- Build indexes. +- Commit the new snapshot. +- Publish `catalog.updated` after the new snapshot is visible. + +Reference finalization may materialize Git-backed references. Integration finalization may update connection projections and publish events. + +Core finalizers always run after plugin transforms for that domain. + +## Add, Remove, And Replace + +When a plugin is added, OpenCode invalidates every domain for which it returned a transform. + +When a plugin is removed, OpenCode removes its hooks and invalidates those domains. Rebuilding from base state automatically removes the plugin's prior mutations. + +When a plugin is replaced, OpenCode swaps its hooks, preserves the intended plugin order, and invalidates the affected domains. + +No plugin-specific undo callback is required. + +## Effect API + +The Effect API exposes Effect-native setup, runtime hooks, scopes, interruption, and typed failures. + +```ts +export type EffectPlugin = (ctx: EffectPluginContext) => Effect.Effect +``` + +The setup scope owns: + +- Event subscriptions +- Watchers +- Background fibers +- Plugin hooks + +Closing the scope unloads the plugin and invalidates its transformed domains. + +## Promise API + +The Promise API uses the same SDK values, hook names, editors, and lifecycle semantics. + +```ts +export default definePlugin({ + id: "reviewer", + plugin: async () => ({ + "agent.transform": (agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code" + item.mode = "subagent" + item.hidden = false + }) + }, + }), +}) +``` + +Promise plugins receive Promise-returning host capabilities: + +```ts +await ctx.catalog.invalidate() +``` + +Core implements the Promise API by running the canonical Effect capabilities. It manages the plugin scope automatically. + +## Rules For Transform Hooks + +Transform hooks must: + +- Be synchronous. +- Be deterministic for their captured snapshot. +- Avoid network, filesystem, process, and database I/O. +- Avoid publishing events. +- Avoid invalidating a domain while that domain is rebuilding. +- Avoid retaining the editor after returning. + +Transform hooks may: + +- Read the editor's current materialized state. +- Add, update, and remove domain entries. +- Install executable callback values for later use. +- Read immutable or plugin-owned captured data. + +## Runtime Requirements + +The plugin runtime must provide these guarantees: + +- Hooks replay in deterministic plugin order. +- Only one rebuild per domain runs at a time. +- Repeated invalidations may be coalesced. +- Rebuilds use fresh temporary state. +- Failed rebuilds leave the previous committed state intact. +- Core finalization runs after all plugin transforms. +- Update events publish only after the new state is visible. +- Plugin add, remove, and replacement invalidate affected domains automatically. +- A transform cannot invalidate the domain currently running it. + +## Summary + +Use setup for effects and transforms for declarations. + +```ts +effect: (ctx) => + Effect.gen(function* () { + let data = yield* loadData() + + yield* watchData.pipe( + Stream.runForEach( + Effect.fn(function* () { + data = yield* loadData() + yield* ctx.catalog.invalidate() + }), + ), + Effect.forkScoped, + ) + + return { + "catalog.transform": (catalog) => { + applyCatalog(data, catalog) + }, + } + }) +``` + +The plugin owns changing source data. The runtime owns hook ordering, replay, invalidation, cleanup, and commit. Core services own their state and finalization. diff --git a/packages/plugin/src/v2/effect/agent.ts b/packages/plugin/src/v2/effect/agent.ts new file mode 100644 index 000000000000..11a8c6c20edf --- /dev/null +++ b/packages/plugin/src/v2/effect/agent.ts @@ -0,0 +1,17 @@ +import type { AgentV2Info } from "@opencode-ai/sdk/v2/types" +import type { Effect } from "effect" +import type { Transformable } from "./registration.js" + +export interface AgentDraft { + list(): readonly AgentV2Info[] + get(id: string): AgentV2Info | undefined + default(id: string | undefined): void + update(id: string, update: (agent: AgentV2Info) => void): void + remove(id: string): void +} + +export interface Agent extends Transformable { + get(id: string): Effect.Effect + default(): Effect.Effect + list(): Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/aisdk.ts b/packages/plugin/src/v2/effect/aisdk.ts new file mode 100644 index 000000000000..579de82496e2 --- /dev/null +++ b/packages/plugin/src/v2/effect/aisdk.ts @@ -0,0 +1,21 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" +import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" +import type { Effect } from "effect" +import type { Hookable } from "./registration.js" + +export interface AISDKHooks { + readonly sdk: (event: { + readonly model: ModelV2Info + readonly package: string + readonly options: Record + sdk?: any + }) => Effect.Effect | void + readonly language: (event: { + readonly model: ModelV2Info + readonly sdk: any + readonly options: Record + language?: LanguageModelV3 + }) => Effect.Effect | void +} + +export interface AISDK extends Hookable {} diff --git a/packages/plugin/src/v2/effect/catalog.ts b/packages/plugin/src/v2/effect/catalog.ts new file mode 100644 index 000000000000..1d44717aefde --- /dev/null +++ b/packages/plugin/src/v2/effect/catalog.ts @@ -0,0 +1,41 @@ +import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types" +import type { Effect } from "effect" +import type { Transformable } from "./registration.js" + +export interface CatalogProviderRecord { + readonly provider: ProviderV2Info + readonly models: ReadonlyMap +} + +export interface CatalogDraft { + readonly provider: { + list(): readonly CatalogProviderRecord[] + get(providerID: string): CatalogProviderRecord | undefined + update(providerID: string, update: (provider: ProviderV2Info) => void): void + remove(providerID: string): void + } + readonly model: { + get(providerID: string, modelID: string): ModelV2Info | undefined + update(providerID: string, modelID: string, update: (model: ModelV2Info) => void): void + remove(providerID: string, modelID: string): void + readonly default: { + get(): { providerID: string; modelID: string } | undefined + set(providerID: string, modelID: string): void + } + } +} + +export interface Catalog extends Transformable { + readonly provider: { + get(id: string): Effect.Effect + list(): Effect.Effect + available(): Effect.Effect + } + readonly model: { + get(providerID: string, modelID: string): Effect.Effect + list(): Effect.Effect + available(): Effect.Effect + default(): Effect.Effect + small(providerID: string): Effect.Effect + } +} diff --git a/packages/plugin/src/v2/effect/command.ts b/packages/plugin/src/v2/effect/command.ts new file mode 100644 index 000000000000..fcb90d19685f --- /dev/null +++ b/packages/plugin/src/v2/effect/command.ts @@ -0,0 +1,15 @@ +import type { CommandV2Info } from "@opencode-ai/sdk/v2/types" +import type { Effect } from "effect" +import type { Transformable } from "./registration.js" + +export interface CommandDraft { + list(): readonly CommandV2Info[] + get(name: string): CommandV2Info | undefined + update(name: string, update: (command: CommandV2Info) => void): void + remove(name: string): void +} + +export interface Command extends Transformable { + get(name: string): Effect.Effect + list(): Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/event.ts b/packages/plugin/src/v2/effect/event.ts new file mode 100644 index 000000000000..e6ea7cf0ce9d --- /dev/null +++ b/packages/plugin/src/v2/effect/event.ts @@ -0,0 +1,10 @@ +import type { Event as SDKEvent } from "@opencode-ai/sdk/v2/types" +import type { Stream } from "effect" + +export type EventMap = { + [Item in SDKEvent as Item["type"]]: Item +} + +export interface Event { + subscribe(type: Type): Stream.Stream +} diff --git a/packages/plugin/src/v2/effect/filesystem.ts b/packages/plugin/src/v2/effect/filesystem.ts new file mode 100644 index 000000000000..d242b2a692f1 --- /dev/null +++ b/packages/plugin/src/v2/effect/filesystem.ts @@ -0,0 +1,17 @@ +import type { FileSystemEntry } from "@opencode-ai/sdk/v2/types" +import type { Effect } from "effect" + +export interface FileSystem { + read(input: { readonly path: string }): Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> + list(input?: { readonly path?: string }): Effect.Effect + find(input: { + readonly query: string + readonly type?: "file" | "directory" + readonly limit?: number + }): Effect.Effect + glob(input: { + readonly pattern: string + readonly path?: string + readonly limit?: number + }): Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/host.ts b/packages/plugin/src/v2/effect/host.ts new file mode 100644 index 000000000000..707f744b35f2 --- /dev/null +++ b/packages/plugin/src/v2/effect/host.ts @@ -0,0 +1,27 @@ +import type { Agent } from "./agent.js" +import type { AISDK } from "./aisdk.js" +import type { Catalog } from "./catalog.js" +import type { Command } from "./command.js" +import type { Event } from "./event.js" +import type { FileSystem } from "./filesystem.js" +import type { Integration } from "./integration.js" +import type { Location } from "./location.js" +import type { Npm } from "./npm.js" +import type { Path } from "./path.js" +import type { Reference } from "./reference.js" +import type { Skill } from "./skill.js" + +export interface PluginHost { + readonly agent: Agent + readonly aisdk: AISDK + readonly catalog: Catalog + readonly command: Command + readonly event: Event + readonly filesystem: FileSystem + readonly integration: Integration + readonly location: Location + readonly npm: Npm + readonly path: Path + readonly reference: Reference + readonly skill: Skill +} diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts new file mode 100644 index 000000000000..bc7a6d7c5cb5 --- /dev/null +++ b/packages/plugin/src/v2/effect/index.ts @@ -0,0 +1,4 @@ +export type { PluginHost } from "./host.js" +export { define } from "./plugin.js" +export type { Plugin } from "./plugin.js" +export type { Registration } from "./registration.js" diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts new file mode 100644 index 000000000000..5e75b4162a3b --- /dev/null +++ b/packages/plugin/src/v2/effect/integration.ts @@ -0,0 +1,30 @@ +import type { + IntegrationEnvMethod, + IntegrationInfo, + IntegrationKeyMethod, + IntegrationOAuthMethod, +} from "@opencode-ai/sdk/v2/types" +import type { Effect } from "effect" +import type { Transformable } from "./registration.js" + +export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod + +export interface IntegrationDraft { + list(): readonly Pick[] + get(id: string): Pick | undefined + update(id: string, update: (integration: Pick) => void): void + remove(id: string): void + readonly method: { + list(integrationID: string): readonly IntegrationMethod[] + update(input: { + readonly integrationID: string + readonly method: IntegrationKeyMethod | IntegrationEnvMethod + }): void + remove(integrationID: string, method: IntegrationMethod): void + } +} + +export interface Integration extends Transformable { + get(id: string): Effect.Effect + list(): Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/location.ts b/packages/plugin/src/v2/effect/location.ts new file mode 100644 index 000000000000..bc546a3b175d --- /dev/null +++ b/packages/plugin/src/v2/effect/location.ts @@ -0,0 +1,6 @@ +export interface Location { + readonly directory: string + readonly project: { + readonly directory: string + } +} diff --git a/packages/plugin/src/v2/effect/npm.ts b/packages/plugin/src/v2/effect/npm.ts new file mode 100644 index 000000000000..4cb96c32d172 --- /dev/null +++ b/packages/plugin/src/v2/effect/npm.ts @@ -0,0 +1,11 @@ +import type { Effect } from "effect" + +export interface Npm { + add(pkg: string): Effect.Effect< + { + readonly directory: string + readonly entrypoint?: string + }, + unknown + > +} diff --git a/packages/plugin/src/v2/effect/path.ts b/packages/plugin/src/v2/effect/path.ts new file mode 100644 index 000000000000..f9045cc32d0f --- /dev/null +++ b/packages/plugin/src/v2/effect/path.ts @@ -0,0 +1,8 @@ +export interface Path { + readonly home: string + readonly data: string + readonly cache: string + readonly config: string + readonly state: string + readonly temp: string +} diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts new file mode 100644 index 000000000000..09c919ad6b06 --- /dev/null +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -0,0 +1,11 @@ +import type { Effect, Scope } from "effect" +import type { PluginHost } from "./host.js" + +export interface Plugin { + readonly id: string + readonly effect: (host: PluginHost) => Effect.Effect +} + +export function define(plugin: Plugin) { + return plugin +} diff --git a/packages/plugin/src/v2/effect/reference.ts b/packages/plugin/src/v2/effect/reference.ts new file mode 100644 index 000000000000..389674cff7e2 --- /dev/null +++ b/packages/plugin/src/v2/effect/reference.ts @@ -0,0 +1,13 @@ +import type { ReferenceGitSource, ReferenceInfo, ReferenceLocalSource } from "@opencode-ai/sdk/v2/types" +import type { Effect } from "effect" +import type { Transformable } from "./registration.js" + +export interface ReferenceDraft { + add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void + remove(name: string): void + list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[] +} + +export interface Reference extends Transformable { + list(): Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/registration.ts b/packages/plugin/src/v2/effect/registration.ts new file mode 100644 index 000000000000..05aa0c4b6062 --- /dev/null +++ b/packages/plugin/src/v2/effect/registration.ts @@ -0,0 +1,16 @@ +import type { Effect, Scope } from "effect" + +export type Transform = (draft: Draft) => Effect.Effect | void + +export interface Registration { + readonly dispose: Effect.Effect +} + +export interface Transformable { + transform(callback: Transform): Effect.Effect + rebuild(): Effect.Effect +} + +export interface Hookable { + hook(name: Name, callback: Hooks[Name]): Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/skill.ts b/packages/plugin/src/v2/effect/skill.ts new file mode 100644 index 000000000000..d25a71f0d3ec --- /dev/null +++ b/packages/plugin/src/v2/effect/skill.ts @@ -0,0 +1,18 @@ +import type { SkillV2Info } from "@opencode-ai/sdk/v2/types" +import type { Effect } from "effect" +import type { Transformable } from "./registration.js" + +export type SkillSource = + | { readonly type: "directory"; readonly path: string } + | { readonly type: "url"; readonly url: string } + | { readonly type: "embedded"; readonly skill: SkillV2Info } + +export interface SkillDraft { + source(source: SkillSource): void + list(): readonly SkillSource[] +} + +export interface Skill extends Transformable { + sources(): Effect.Effect + list(): Effect.Effect +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 4c153e4ac378..12fd39a84a8d 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -15,7 +15,8 @@ "./v2": "./src/v2/index.ts", "./v2/client": "./src/v2/client.ts", "./v2/gen/client": "./src/v2/gen/client/index.ts", - "./v2/server": "./src/v2/server.ts" + "./v2/server": "./src/v2/server.ts", + "./v2/types": "./src/v2/gen/types.gen.ts" }, "files": [ "dist"