Compare commits

..

1 Commits

Author SHA1 Message Date
James Long 0984208948 test(core): simplify file mutation layer wiring 2026-06-20 21:55:08 -04:00
175 changed files with 2760 additions and 4393 deletions
+1 -2
View File
@@ -99,8 +99,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
node-version: "24.15"
node-version: "24"
- name: Setup Bun
uses: ./.github/actions/setup-bun
-2
View File
@@ -276,7 +276,6 @@
"@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",
@@ -626,7 +625,6 @@
"name": "@opencode-ai/plugin",
"version": "1.17.9",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
"zod": "catalog:",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=",
"aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=",
"aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=",
"x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs="
"x86_64-linux": "sha256-g0tDvRf7MErZ1PEeUazEYi492ZHiRT8kYv3bPdkss/I=",
"aarch64-linux": "sha256-6sKgf3ftbIqlPxlFkoPzoWPsJp3IwXD+H3Y6g874xmk=",
"aarch64-darwin": "sha256-Se/Nls/KlkuK2ysDQ9DeAzSaX3NsL2iDdf/dsv2GIXc=",
"x86_64-darwin": "sha256-V9MCkqnvQ1nkD2PaaTfNFKkBZGymj6KxrSAK6+DTF8Y="
}
}
-1
View File
@@ -91,7 +91,6 @@
"@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",
+14 -9
View File
@@ -1,6 +1,7 @@
export * as AgentV2 from "./agent"
import { Array, Context, Effect, Layer, Schema, Scope, Types } from "effect"
import { Array, Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PermissionSchema } from "./permission/schema"
import { ProviderV2 } from "./provider"
@@ -48,19 +49,21 @@ export interface Selection {
}
type Data = {
agents: Map<ID, Types.DeepMutable<Info>>
agents: Map<ID, Info>
default?: ID
}
export type Draft = {
export type Editor = {
list: () => readonly Info[]
get: (id: ID) => Info | undefined
default: (id: ID | undefined) => void
update: (id: ID, fn: (agent: Types.DeepMutable<Info>) => void) => void
update: (id: ID, fn: (agent: Draft<Info>) => void) => void
remove: (id: ID) => void
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly default: () => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
@@ -70,19 +73,21 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
enableMapSet()
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
initial: () => ({ agents: new Map() }),
draft: (draft) => ({
editor: (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) ?? (Info.empty(id) as Types.DeepMutable<Info>)
const current = draft.agents.get(id) ?? castDraft(Info.empty(id))
if (!draft.agents.has(id)) draft.agents.set(id, current)
fn(current)
current.id = id
@@ -108,7 +113,7 @@ export const layer = Layer.effect(
return Service.of({
transform: state.transform,
rebuild: state.rebuild,
update: state.update,
get: Effect.fn("AgentV2.get")(function* (id) {
return state.get().agents.get(id)
}),
+93 -51
View File
@@ -1,21 +1,36 @@
export * as Catalog from "./catalog"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
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"
import { ProviderV2 } from "./provider"
import { Location } from "./location"
import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state"
import { Integration } from "./integration"
export type ProviderRecord = {
provider: ProviderV2.MutableInfo
models: Map<ModelV2.ID, ModelV2.MutableInfo>
provider: ProviderV2.Info
models: Map<ModelV2.ID, ModelV2.Info>
}
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"CatalogV2.ProviderNotFound",
{
providerID: ProviderV2.ID,
},
) {}
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("CatalogV2.ModelNotFound", {
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
}) {}
export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = {
@@ -27,16 +42,16 @@ type Data = {
defaultModel?: DefaultModel
}
export type Draft = {
export type Editor = {
provider: {
list: () => readonly ProviderRecord[]
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void
update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => 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: ModelV2.MutableInfo) => void) => void
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
default: {
get: () => DefaultModel | undefined
@@ -45,29 +60,38 @@ export type Draft = {
}
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly provider: {
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
readonly all: () => Effect.Effect<ProviderV2.Info[]>
readonly available: () => Effect.Effect<ProviderV2.Info[]>
}
readonly model: {
readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info | undefined>
readonly get: (
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
readonly all: () => Effect.Effect<ModelV2.Info[]>
readonly available: () => Effect.Effect<ModelV2.Info[]>
readonly default: () => Effect.Effect<ModelV2.Info | undefined>
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<Option.Option<ModelV2.Info>>
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
enableMapSet()
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const policy = yield* Policy.Service
const integrations = yield* Integration.Service
const scope = yield* Scope.Scope
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
if (provider.disabled) return false
@@ -96,26 +120,32 @@ export const layer = Layer.effect(
})
}
const normalizeApi = (item: ProviderV2.MutableInfo | ModelV2.MutableInfo) => {
function* getRecord(providerID: ProviderV2.ID) {
const match = state.get().providers.get(providerID)
if (!match) return yield* new ProviderNotFoundError({ providerID })
return match
}
const normalizeApi = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
if (typeof item.request.body.baseURL !== "string") return
item.api.url = item.request.body.baseURL
delete item.request.body.baseURL
}
const state = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
initial: () => ({ providers: new Map() }),
draft: (draft) => {
const result: Draft = {
editor: (draft) => {
const result: Editor = {
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 = {
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
}
current = castDraft({
provider: ProviderV2.Info.empty(providerID),
models: new Map<ModelV2.ID, ModelV2.Info>(),
})
draft.providers.set(providerID, current)
}
fn(current.provider)
@@ -130,14 +160,13 @@ export const layer = Layer.effect(
update: (providerID, modelID, fn) => {
let record = draft.providers.get(providerID)
if (!record) {
record = {
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
}
record = castDraft({
provider: ProviderV2.Info.empty(providerID),
models: new Map<ModelV2.ID, ModelV2.Info>(),
})
draft.providers.set(providerID, record)
}
const model =
record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo)
const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID))
if (!record.models.has(modelID)) record.models.set(modelID, model)
fn(model)
model.id = modelID
@@ -157,7 +186,8 @@ export const layer = Layer.effect(
}
return result
},
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
if (reason !== "plugin.added") 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") {
@@ -168,13 +198,25 @@ export const layer = Layer.effect(
yield* events.publish(Event.Updated, {})
}),
})
yield* events.subscribe(PluginV2.Event.Added).pipe(
// Plugin registries are location scoped even though the event bus is process scoped.
Stream.filter(
(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"),
),
Effect.forkIn(scope, { startImmediately: true }),
)
const result: Interface = {
transform: state.transform,
rebuild: state.rebuild,
provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
return state.get().providers.get(providerID)?.provider
const record = yield* getRecord(providerID)
return record.provider
}),
all: Effect.fn("CatalogV2.provider.all")(function* () {
@@ -196,10 +238,10 @@ export const layer = Layer.effect(
model: {
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
const record = state.get().providers.get(providerID)
if (!record) return
const record = yield* getRecord(providerID)
const model = record.models.get(modelID)
return model && projectModel(model, record.provider)
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
return projectModel(model, record.provider)
}),
all: Effect.fn("CatalogV2.model.all")(function* () {
@@ -208,7 +250,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, Order.flip(Order.Number)),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
)
}),
@@ -220,30 +262,31 @@ export const layer = Layer.effect(
default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel
if (defaultModel) {
const provider = yield* result.provider.get(defaultModel.providerID)
if (provider && (yield* result.provider.available()).some((item) => item.id === provider.id)) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID)
if (model?.enabled) return model
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
if (
Option.isSome(provider) &&
(yield* result.provider.available()).some((item) => item.id === provider.value.id)
) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
if (Option.isSome(model) && model.value.enabled) return model
}
}
return Option.getOrUndefined(
pipe(
yield* result.model.available(),
Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
Array.head,
),
return pipe(
yield* result.model.available(),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
Array.head,
)
}),
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID)
if (!record) return
if (!record) return Option.none<ModelV2.Info>()
const provider = record.provider
if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
}
const candidates = pipe(
@@ -259,7 +302,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) / (1000 * 60 * 60 * 24 * 30),
age: (Date.now() - model.time.released.epochMilliseconds) / (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),
@@ -276,12 +319,10 @@ export const layer = Layer.effect(
)
}
return Option.getOrUndefined(
pipe(
candidates,
Array.filter((item) => item.small),
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
),
return pipe(
candidates,
Array.filter((item) => item.small),
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
)
}),
},
@@ -295,5 +336,6 @@ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Policy.locationLayer),
)
+12 -9
View File
@@ -1,6 +1,7 @@
export * as CommandV2 from "./command"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { castDraft, type Draft } from "immer"
import { ModelV2 } from "./model"
import { State } from "./state"
@@ -14,17 +15,19 @@ export class Info extends Schema.Class<Info>("CommandV2.Info")({
}) {}
export type Data = {
commands: Map<string, Types.DeepMutable<Info>>
commands: Map<string, Info>
}
export type Draft = {
export type Editor = {
list: () => readonly Info[]
get: (name: string) => Info | undefined
update: (name: string, update: (command: Types.DeepMutable<Info>) => void) => void
update: (name: string, update: (command: Draft<Info>) => void) => void
remove: (name: string) => void
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
@@ -34,13 +37,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.sync(() => {
const state = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
initial: () => ({ commands: new Map() }),
draft: (draft) => ({
editor: (draft) => ({
list: () => Array.from(draft.commands.values()) as Info[],
get: (name) => draft.commands.get(name),
update: (name, update) => {
const current = draft.commands.get(name) ?? (new Info({ name, template: "" }) as Types.DeepMutable<Info>)
const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" }))
if (!draft.commands.has(name)) draft.commands.set(name, current)
update(current)
current.name = name
@@ -52,7 +55,7 @@ export const layer = Layer.effect(
})
return Service.of({
rebuild: state.rebuild,
update: state.update,
transform: state.transform,
get: Effect.fn("CommandV2.get")(function* (name) {
return state.get().commands.get(name)
+59 -59
View File
@@ -1,6 +1,5 @@
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"
@@ -9,6 +8,7 @@ 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 = define({
id: "config-agent",
effect: Effect.fn(function* (ctx) {
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-agent"),
effect: Effect.gen(function* () {
const agent = yield* AgentV2.Service
const config = yield* Config.Service
const fs = yield* FSUtil.Service
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))
}
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()))
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
}
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))
}
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)
})
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 = 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)
})
}
}),
)
}
})
}),
})
+33 -32
View File
@@ -1,51 +1,52 @@
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 = define({
id: "config-command",
effect: Effect.fn(function* (ctx) {
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-command"),
effect: Effect.gen(function* () {
const command = yield* CommandV2.Service
const config = yield* Config.Service
const fs = yield* FSUtil.Service
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
})
}
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
})
}
}),
)
}
})
}),
})
+102 -103
View File
@@ -1,124 +1,123 @@
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 = define({
id: "config-provider",
effect: Effect.fn(function* (ctx) {
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-provider"),
effect: Effect.gen(function* () {
const catalog = yield* Catalog.Service
const config = yield* Config.Service
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
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] },
})
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
}
}
}
}),
)
}
})
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)
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
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],
}
}
})
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, 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: {},
}
model.variants.push(existing)
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: {},
}
ModelRequest.assign(existing, {
headers: variant.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
})
model.variants.push(existing)
}
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 }
})
}
}
}),
)
}
})
}),
})
+43 -38
View File
@@ -1,52 +1,57 @@
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 = define({
id: "core/config-reference",
effect: Effect.fn(function* (ctx) {
export const Plugin = {
id: PluginV2.ID.make("core/config-reference"),
effect: Effect.gen(function* () {
const config = yield* Config.Service
yield* ctx.reference.transform(
Effect.fn(function* (draft) {
const entries = new Map<string, Reference.Source>()
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)
}),
)
const global = yield* Global.Service
const location = yield* Location.Service
const references = yield* Reference.Service
const update = yield* references.transform()
const entries = new Map<string, Reference.Source>()
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)
})
}),
})
}
function validAlias(name: string) {
return name.length > 0 && !/[\/\s`,]/.test(name)
+36 -33
View File
@@ -1,45 +1,48 @@
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 = define({
id: "config-skill",
effect: Effect.fn(function* (ctx) {
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-skill"),
effect: Effect.gen(function* () {
const config = yield* Config.Service
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")) }),
)
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
}
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)),
}),
)
}
})
}),
})
+177 -130
View File
@@ -5,7 +5,7 @@ import { and, asc, eq, gt } from "drizzle-orm"
import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
import { externalID, type ExternalID, withStatics } from "./schema"
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { LayerNode } from "./effect/layer-node"
import { isDeepStrictEqual } from "node:util"
@@ -19,9 +19,16 @@ export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
)
export type ID = typeof ID.Type
/**
* Durable aggregate continuation position for embedded replay streams.
* TODO: Decide whether a future HTTP / SDK surface should expose an opaque cursor instead.
*/
export const Cursor = NonNegativeInt.pipe(Schema.brand("EventV2.Cursor"))
export type Cursor = typeof Cursor.Type
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
readonly type: Type
readonly durable?: {
readonly sync?: {
readonly version: number
readonly aggregate: string
}
@@ -34,16 +41,20 @@ export type Payload<D extends Definition = Definition> = {
readonly id: ID
readonly type: D["type"]
readonly data: Data<D>
readonly durable?: {
readonly aggregateID: string
readonly seq: number
readonly version: number
}
/** Durable aggregate order, populated while synchronized events are projected. */
readonly seq?: number
readonly version?: number
readonly location?: Location.Ref
readonly metadata?: Record<string, unknown>
/** Internal replay marker for projectors that own non-replicated operational state. */
readonly replay?: boolean
}
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
type AnyProjector = (event: Payload) => Effect.Effect<void>
export type CommitGuard = (event: Payload) => Effect.Effect<void>
export type Listener = (event: Payload) => Effect.Effect<void>
export type Sync = (event: Payload) => Effect.Effect<void>
export type Unsubscribe = Effect.Effect<void>
export type SerializedEvent = {
@@ -54,8 +65,13 @@ export type SerializedEvent = {
readonly data: Record<string, unknown>
}
export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDurableEventError>()(
"EventV2.InvalidDurableEvent",
export type CursorEvent<E extends Payload = Payload> = {
readonly cursor: Cursor
readonly event: E
}
export class InvalidSyncEventError extends Schema.TaggedErrorClass<InvalidSyncEventError>()(
"EventV2.InvalidSyncEvent",
{
type: Schema.String,
message: Schema.String,
@@ -67,11 +83,19 @@ export function versionedType(type: string, version: number) {
}
export const registry = new Map<string, Definition>()
const durableRegistry = new Map<string, Definition>()
type SyncDefinition = Definition & {
readonly sync: NonNullable<Definition["sync"]>
readonly encode: (data: unknown) => unknown
readonly decode: (data: unknown) => unknown
}
const syncRegistry = new Map<string, SyncDefinition>()
// Synchronized events cross a JSON boundary, so their data schemas must encode and decode without services.
const syncCodec = (definition: Definition) => definition.data as Schema.Codec<unknown, unknown, never, never>
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
readonly type: Type
readonly durable?: {
readonly sync?: {
readonly version: number
readonly aggregate: string
}
@@ -82,25 +106,28 @@ export function define<const Type extends string, Fields extends Schema.Struct.F
id: ID,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type),
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
version: Schema.optional(Schema.Number),
location: Schema.optional(Location.Ref),
data: Data,
}).annotate({ identifier: input.type })
const definition = Object.assign(Payload, {
type: input.type,
...(input.durable === undefined ? {} : { durable: input.durable }),
...(input.sync === undefined ? {} : { sync: input.sync }),
data: Data,
})
const existing = registry.get(input.type)
if (
input.durable === undefined ||
existing?.durable === undefined ||
input.durable.version >= existing.durable.version
) {
if (input.sync === undefined || existing?.sync === undefined || input.sync.version >= existing.sync.version) {
registry.set(input.type, definition)
}
if (input.durable) durableRegistry.set(versionedType(input.type, input.durable.version), definition)
if (input.sync)
syncRegistry.set(
versionedType(input.type, input.sync.version),
Object.assign(definition, {
encode: Schema.encodeUnknownSync(syncCodec(definition)),
decode: Schema.decodeUnknownSync(syncCodec(definition)),
}) as SyncDefinition,
)
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
Definition<Type, Schema.Struct<Fields>>
}
@@ -113,7 +140,7 @@ export interface PublishOptions {
readonly id?: ID
readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
/** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */
readonly commit?: (seq: number) => Effect.Effect<void>
}
@@ -125,10 +152,14 @@ export interface Interface {
) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly all: () => Stream.Stream<Payload>
readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream<Payload>
/** @deprecated Use `all()` and consume the returned stream. */
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
readonly aggregateEvents: (input: {
readonly aggregateID: string
readonly after?: Cursor
}) => Stream.Stream<CursorEvent>
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
readonly project: <D extends Definition>(definition: D, projector: Projector<D>) => Effect.Effect<void>
readonly replay: (
event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
@@ -151,37 +182,37 @@ export const layerWith = (options?: LayerOptions) =>
Layer.effect(
Service,
Effect.gen(function* () {
const pubsub = {
all: yield* PubSub.unbounded<Payload>(),
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
typed: new Map<string, PubSub.PubSub<Payload>>(),
}
const projectors = new Map<string, Subscriber[]>()
const listeners = new Array<Subscriber>()
const all = yield* PubSub.unbounded<Payload>()
const synchronized = new Map<string, Set<PubSub.PubSub<void>>>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
const projectors = new Map<string, AnyProjector[]>()
const commitGuards = new Array<CommitGuard>()
const listeners = new Array<Listener>()
const syncHandlers = new Array<Sync>()
const { db } = yield* Database.Service
const getOrCreate = (definition: Definition) =>
Effect.gen(function* () {
const existing = pubsub.typed.get(definition.type)
const existing = typed.get(definition.type)
if (existing) return existing
const created = yield* PubSub.unbounded<Payload>()
pubsub.typed.set(definition.type, created)
return created
const pubsub = yield* PubSub.unbounded<Payload>()
typed.set(definition.type, pubsub)
return pubsub
})
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* PubSub.shutdown(pubsub.all)
yield* PubSub.shutdown(all)
yield* Effect.forEach(
pubsub.durable.values(),
synchronized.values(),
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
{ discard: true },
)
yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true })
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
}),
)
function commitDurableEvent(
function commitSyncEvent(
event: Payload,
input?: {
readonly seq: number
@@ -193,20 +224,28 @@ export const layerWith = (options?: LayerOptions) =>
) {
return Effect.gen(function* () {
const definition = registry.get(event.type)
const durable = definition?.durable
if (durable) {
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
const sync = definition?.sync
if (sync) {
if (event.version !== sync.version) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected event version ${sync.version}, got ${event.version}`,
}),
)
}
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
if (typeof aggregateID !== "string") {
yield* Effect.die(
new InvalidDurableEventError({
new InvalidSyncEventError({
type: event.type,
message: `Expected string aggregate field ${durable.aggregate}`,
message: `Expected string aggregate field ${sync.aggregate}`,
}),
)
} else {
if (input && input.aggregateID !== aggregateID) {
yield* Effect.die(
new InvalidDurableEventError({
new InvalidSyncEventError({
type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}),
@@ -226,12 +265,12 @@ export const layerWith = (options?: LayerOptions) =>
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
const encoded = Schema.encodeUnknownSync(
definition.data as Schema.Codec<unknown, unknown, never, never>,
)(event.data) as Record<string, unknown>
const encoded = syncRegistry
.get(versionedType(definition.type, sync.version))!
.encode(event.data) as Record<string, unknown>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die(
new InvalidDurableEventError({
new InvalidSyncEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
@@ -246,7 +285,7 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie)
if (
stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) &&
stored.type === versionedType(definition.type, sync.version) &&
isDeepStrictEqual(stored.data, encoded)
) {
if (input.ownerID && row?.ownerID == null) {
@@ -260,7 +299,7 @@ export const layerWith = (options?: LayerOptions) =>
return
}
yield* Effect.die(
new InvalidDurableEventError({
new InvalidSyncEventError({
type: event.type,
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
}),
@@ -272,7 +311,7 @@ export const layerWith = (options?: LayerOptions) =>
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidDurableEventError({
new InvalidSyncEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
@@ -286,17 +325,16 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidDurableEventError({
new InvalidSyncEventError({
type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
const committed = {
...event,
durable: { aggregateID, seq, version: durable.version },
} as Payload
for (const guard of commitGuards) {
yield* guard(event)
}
for (const projector of list) {
yield* projector(committed)
yield* projector({ ...event, seq } as Payload)
}
if (commit) yield* commit(seq)
yield* db
@@ -318,7 +356,7 @@ export const layerWith = (options?: LayerOptions) =>
id: event.id,
aggregate_id: aggregateID,
seq,
type: versionedType(definition.type, durable.version),
type: versionedType(definition.type, sync.version),
data: encoded,
},
])
@@ -331,8 +369,8 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie)
if (committed) {
yield* Effect.forEach(
pubsub.durable.get(committed.aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
synchronized.get(committed.aggregateID) ?? [],
(pubsub) => PubSub.publish(pubsub, undefined),
{ discard: true },
)
}
@@ -346,25 +384,19 @@ export const layerWith = (options?: LayerOptions) =>
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
return Effect.gen(function* () {
const definition = registry.get(event.type)
if (!definition?.durable && commit)
const durable = registry.get(event.type)?.sync !== undefined
if (!durable && commit)
return yield* Effect.die(
new InvalidDurableEventError({
new InvalidSyncEventError({
type: event.type,
message: "Local commit hooks require a durable event",
message: "Local commit hooks require a synchronized event",
}),
)
if (definition?.durable) {
const committed = yield* commitDurableEvent(event as Payload, undefined, commit)
if (durable) {
const committed = yield* commitSyncEvent(event as Payload, undefined, commit)
if (committed) {
event = {
...event,
durable: {
aggregateID: committed.aggregateID,
seq: committed.seq,
version: definition.durable.version,
},
}
event = { ...event, seq: committed.seq }
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
yield* notify(event as Payload, true)
return event
}
@@ -374,11 +406,12 @@ export const layerWith = (options?: LayerOptions) =>
})
}
const observe = (event: Payload, observer: (event: Payload) => Effect.Effect<void>) =>
const observe = (event: Payload, kind: "sync" | "listener", observer: (event: Payload) => Effect.Effect<void>) =>
Effect.suspend(() => observer(event)).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) => Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }),
(cause) =>
Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }),
),
)
@@ -386,12 +419,12 @@ export const layerWith = (options?: LayerOptions) =>
return Effect.gen(function* () {
yield* Effect.forEach(
listeners,
(listener) => (isolateListeners ? observe(event, listener) : listener(event)),
(listener) => (isolateListeners ? observe(event, "listener", listener) : listener(event)),
{ discard: true },
)
const typed = pubsub.typed.get(event.type)
if (typed) yield* PubSub.publish(typed, event)
yield* PubSub.publish(pubsub.all, event)
const pubsub = typed.get(event.type)
if (pubsub) yield* PubSub.publish(pubsub, event)
yield* PubSub.publish(all, event)
})
}
@@ -408,6 +441,7 @@ export const layerWith = (options?: LayerOptions) =>
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
...(location ? { location } : {}),
data,
} as Payload<D>,
@@ -421,37 +455,27 @@ export const layerWith = (options?: LayerOptions) =>
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
) {
return Effect.gen(function* () {
const definition = durableRegistry.get(event.type)
if (!definition?.durable) {
const definition = syncRegistry.get(event.type)
if (!definition) {
yield* Effect.die(
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
)
} else {
const payload = {
id: event.id,
type: definition.type,
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(
event.data,
),
version: definition.sync.version,
data: definition.decode(event.data),
replay: true,
} as Payload
const committed = yield* commitDurableEvent(payload, {
const committed = yield* commitSyncEvent(payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
yield* notify(
{
...payload,
durable: {
aggregateID: committed.aggregateID,
seq: committed.seq,
version: definition.durable.version,
},
},
true,
)
yield* notify({ ...payload, seq: committed.seq }, true)
}
}
})
@@ -466,7 +490,7 @@ export const layerWith = (options?: LayerOptions) =>
if (!source) return undefined
if (events.some((event) => event.aggregateID !== source)) {
yield* Effect.die(
new InvalidDurableEventError({
new InvalidSyncEventError({
type: events[0]?.type ?? "unknown",
message: "Replay events must belong to the same aggregate",
}),
@@ -477,7 +501,7 @@ export const layerWith = (options?: LayerOptions) =>
const seq = start + index
if (event.seq !== seq) {
yield* Effect.die(
new InvalidDurableEventError({
new InvalidSyncEventError({
type: event.type,
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
}),
@@ -516,18 +540,22 @@ export const layerWith = (options?: LayerOptions) =>
Stream.map((event) => event as Payload<D>),
)
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
const definition = durableRegistry.get(event.type)
if (!definition?.durable) {
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => {
const definition = syncRegistry.get(event.type)
if (!definition) {
throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` })
}
return {
id: event.id,
type: definition.type,
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data),
cursor: Cursor.make(event.seq),
event: {
id: event.id,
type: definition.type,
version: definition.sync.version,
seq: event.seq,
data: definition.decode(event.data),
},
}
}
@@ -555,40 +583,43 @@ export const layerWith = (options?: LayerOptions) =>
),
)
const subscribeDurable = (aggregateID: string) =>
const subscribeSynchronized = (aggregateID: string) =>
Effect.gen(function* () {
const wake = yield* PubSub.sliding<void>(1)
const subscription = yield* PubSub.subscribe(wake)
const pubsub = yield* PubSub.sliding<void>(1)
const subscription = yield* PubSub.subscribe(pubsub)
yield* Effect.acquireRelease(
Effect.sync(() => {
const wakes = pubsub.durable.get(aggregateID) ?? new Set()
wakes.add(wake)
pubsub.durable.set(aggregateID, wakes)
const pubsubs = synchronized.get(aggregateID) ?? new Set()
pubsubs.add(pubsub)
synchronized.set(aggregateID, pubsubs)
}),
() =>
Effect.sync(() => {
const wakes = pubsub.durable.get(aggregateID)
wakes?.delete(wake)
if (wakes?.size === 0) pubsub.durable.delete(aggregateID)
}).pipe(Effect.andThen(PubSub.shutdown(wake))),
const pubsubs = synchronized.get(aggregateID)
pubsubs?.delete(pubsub)
if (pubsubs?.size === 0) synchronized.delete(aggregateID)
}).pipe(Effect.andThen(PubSub.shutdown(pubsub))),
)
return subscription
})
const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream<Payload> =>
const streamEvents = (input: {
readonly aggregateID: string
readonly after?: Cursor
}): Stream.Stream<CursorEvent> =>
Stream.unwrap(
Effect.gen(function* () {
const wakes = yield* subscribeDurable(input.aggregateID)
let sequence = input.after ?? -1
const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
const synchronized = yield* subscribeSynchronized(input.aggregateID)
let cursor = input.after ?? -1
const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe(
Effect.tap((events) =>
Effect.sync(() => {
sequence = events.at(-1)?.durable?.seq ?? sequence
cursor = events.at(-1)?.cursor ?? cursor
}),
),
)
const historical = yield* read
const live = Stream.fromSubscription(wakes).pipe(
const live = Stream.fromSubscription(synchronized).pipe(
Stream.mapEffect(() => read),
Stream.flattenIterable,
)
@@ -596,7 +627,7 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
listeners.push(listener)
return Effect.sync(() => {
@@ -605,7 +636,21 @@ export const layerWith = (options?: LayerOptions) =>
})
})
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
syncHandlers.push(handler)
return Effect.sync(() => {
const index = syncHandlers.indexOf(handler)
if (index >= 0) syncHandlers.splice(index, 1)
})
})
const beforeCommit = (guard: CommitGuard): Effect.Effect<void> =>
Effect.sync(() => {
commitGuards.push(guard)
})
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
Effect.sync(() => {
const list = projectors.get(definition.type) ?? []
list.push((event) => projector(event as Payload<D>))
@@ -616,8 +661,10 @@ export const layerWith = (options?: LayerOptions) =>
publish,
subscribe,
all: streamAll,
durable,
aggregateEvents: streamEvents,
sync,
listen,
beforeCommit,
project,
replay,
replayAll,
+3
View File
@@ -3,6 +3,7 @@ export * as FileMutation from "./file-mutation"
import { Context, Effect, Layer, Schema } from "effect"
import { dirname } from "path"
import { KeyedMutex } from "./effect/keyed-mutex"
import { LayerNode } from "./effect/layer-node"
import { FSUtil } from "./fs-util"
export interface Target {
@@ -171,6 +172,8 @@ export const layer = Layer.effect(
}),
)
export const node = LayerNode.make(layer, [FSUtil.node])
function splitBom(text: string) {
const stripped = text.replace(/^\uFEFF+/, "")
return { bom: stripped.length !== text.length, text: stripped }
+13 -14
View File
@@ -7,8 +7,8 @@ import { FSUtil } from "./fs-util"
import { Location } from "./location"
import { PositiveInt, RelativePath } from "./schema"
import { FileSystemSearch } from "./filesystem/search"
import { Entry, Match, PathError } from "./filesystem/schema"
export { Entry, Match, PathError, Submatch } from "./filesystem/schema"
import { Entry, Match } from "./filesystem/schema"
export { Entry, Match, Submatch } from "./filesystem/schema"
export const ReadInput = Schema.Struct({
path: RelativePath,
@@ -58,10 +58,8 @@ export const Event = {
}
export interface Interface {
readonly read: (
input: ReadInput,
) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, PathError | FSUtil.Error>
readonly list: (input?: ListInput) => Effect.Effect<Entry[], PathError | FSUtil.Error>
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
@@ -79,9 +77,9 @@ const baseLayer = Layer.effect(
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
const absolute = path.resolve(location.directory, input ?? ".")
if (!FSUtil.contains(location.directory, absolute))
return yield* new PathError({ path: input ?? ".", reason: "lexical_escape" })
const real = yield* fs.realPath(absolute)
if (!FSUtil.contains(root, real)) return yield* new PathError({ path: input ?? ".", reason: "symlink_escape" })
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, directory: location.directory, root }
})
return Service.of({
@@ -90,18 +88,19 @@ const baseLayer = Layer.effect(
grep: search.grep,
read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real)
if (info.type !== "File") return yield* new PathError({ path: input.path, reason: "not_file" })
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
return {
content: yield* fs.readFile(target.real),
content: yield* fs.readFile(target.real).pipe(Effect.orDie),
mime: FSUtil.mimeType(target.real),
}
}),
list: Effect.fn("FileSystem.list")(function* (input = {}) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real)
if (info.type !== "Directory") return yield* new PathError({ path: input.path ?? ".", reason: "not_directory" })
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(target.real).pipe(
Effect.orDie,
Effect.map((items) =>
items
.flatMap((item) => {
-5
View File
@@ -21,8 +21,3 @@ export class Match extends Schema.Class<Match>("FileSystem.Match")({
text: Schema.String,
submatches: Schema.Array(Submatch),
}) {}
export class PathError extends Schema.TaggedErrorClass<PathError>()("FileSystem.PathError", {
path: Schema.String,
reason: Schema.Literals(["lexical_escape", "symlink_escape", "not_file", "not_directory"]),
}) {}
+43 -55
View File
@@ -1,19 +1,7 @@
export * as Integration from "./integration"
import {
Cause,
Clock,
Context,
Duration,
Effect,
Exit,
Layer,
Schedule,
Schema,
Scope,
SynchronizedRef,
Types,
} from "effect"
import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { Credential } from "./credential"
import { IntegrationSchema } from "./integration/schema"
import { withStatics } from "./schema"
@@ -54,14 +42,12 @@ export const SelectPrompt = Schema.Struct({
type: Schema.Literal("select"),
key: Schema.String,
message: Schema.String,
options: Schema.mutable(
Schema.Array(
Schema.Struct({
label: Schema.String,
value: Schema.String,
hint: Schema.optional(Schema.String),
}),
),
options: Schema.Array(
Schema.Struct({
label: Schema.String,
value: Schema.String,
hint: Schema.optional(Schema.String),
}),
),
when: Schema.optional(When),
}).annotate({ identifier: "Integration.SelectPrompt" })
@@ -74,7 +60,7 @@ export const OAuthMethod = Schema.Struct({
id: MethodID,
type: Schema.Literal("oauth"),
label: Schema.String,
prompts: Schema.optional(Schema.mutable(Schema.Array(Prompt))),
prompts: Schema.optional(Schema.Array(Prompt)),
}).annotate({ identifier: "Integration.OAuthMethod" })
export type OAuthMethod = typeof OAuthMethod.Type
@@ -86,7 +72,7 @@ export type KeyMethod = typeof KeyMethod.Type
export const EnvMethod = Schema.Struct({
type: Schema.Literal("env"),
names: Schema.mutable(Schema.Array(Schema.String)),
names: Schema.Array(Schema.String),
}).annotate({ identifier: "Integration.EnvMethod" })
export type EnvMethod = typeof EnvMethod.Type
@@ -96,8 +82,8 @@ export type Method = typeof Method.Type
export class Info extends Schema.Class<Info>("Integration.Info")({
id: ID,
name: Schema.String,
methods: Schema.mutable(Schema.Array(Method)),
connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
methods: Schema.Array(Method),
connections: Schema.Array(IntegrationConnection.Info),
}) {}
export type Inputs = Readonly<{ [key: string]: string }>
@@ -186,19 +172,19 @@ export type Ref = {
}
type Entry = {
ref: Types.DeepMutable<Ref>
methods: Types.DeepMutable<Method>[]
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
ref: Ref
methods: Method[]
implementations: Map<MethodID, OAuthImplementation>
}
type Data = {
integrations: Map<ID, Entry>
}
export type Draft = {
export type Editor = {
list: () => readonly Ref[]
get: (id: ID) => Ref | undefined
update: (id: ID, update: (integration: Types.DeepMutable<Ref>) => void) => void
update: (id: ID, update: (integration: Draft<Ref>) => void) => void
remove: (id: ID) => void
method: {
list: (integrationID: ID) => readonly Method[]
@@ -207,8 +193,11 @@ export type Draft = {
}
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface {
/** Registers a scoped transform over the integration registry. */
readonly transform: State.Interface<Data, Editor>["transform"]
/** Registers and immediately applies a scoped integration registry update. */
readonly update: State.Interface<Data, Editor>["update"]
/** Returns one integration with its methods and current connections. */
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Returns all integrations with their methods and current connections. */
@@ -263,6 +252,8 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
enableMapSet()
const attemptLifetime = Duration.toMillis(Duration.minutes(10))
const terminalRetention = Duration.toMillis(Duration.minutes(1))
const scrubInterval = Duration.seconds(30)
@@ -293,17 +284,15 @@ export const locationLayer = Layer.effect(
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
const state = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
initial: () => ({ integrations: new Map<ID, Entry>() }),
draft: (draft) => ({
editor: (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) ?? {
ref: { id, name: id },
methods: [],
implementations: new Map(),
}
const current =
draft.integrations.get(id) ??
castDraft({ ref: { id, name: id } as Ref, methods: [], implementations: new Map() })
if (!draft.integrations.has(id)) draft.integrations.set(id, current)
update(current.ref)
current.ref.id = id
@@ -312,14 +301,16 @@ 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) ?? {
ref: {
id: implementation.integrationID,
name: implementation.integrationID,
},
methods: [],
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
}
const current =
draft.integrations.get(implementation.integrationID) ??
castDraft({
ref: {
id: implementation.integrationID,
name: implementation.integrationID,
} as Ref,
methods: [],
implementations: new Map<MethodID, OAuthImplementation>(),
})
if (!draft.integrations.has(implementation.integrationID)) {
draft.integrations.set(implementation.integrationID, current)
}
@@ -328,13 +319,10 @@ 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(implementation.method as Types.DeepMutable<Method>)
else current.methods[index] = implementation.method as Types.DeepMutable<Method>
if (implementation.method.type === "oauth") {
current.implementations.set(
implementation.method.id,
implementation as Types.DeepMutable<OAuthImplementation>,
)
if (index === -1) current.methods.push(castDraft(implementation.method))
else current.methods[index] = castDraft(implementation.method)
if (isOAuthImplementation(implementation)) {
current.implementations.set(implementation.method.id, castDraft(implementation))
}
},
remove: (integrationID, method) => {
@@ -446,7 +434,7 @@ export const locationLayer = Layer.effect(
return Service.of({
transform: state.transform,
rebuild: state.rebuild,
update: state.update,
get: Effect.fn("Integration.get")(function* (id) {
const entry = state.get().integrations.get(id)
if (!entry) return undefined
+3
View File
@@ -2,6 +2,7 @@ export * as LocationMutation from "./location-mutation"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { LayerNode } from "./effect/layer-node"
import { FSUtil } from "./fs-util"
import { Location } from "./location"
@@ -153,3 +154,5 @@ export const layer = Layer.effect(
)
export const locationLayer = layer
export const node = (location: LayerNode.Node<Location.Service>) => LayerNode.make(layer, [FSUtil.node, location])
+1 -1
View File
@@ -33,7 +33,7 @@ export type Request = typeof Request.Type
interface MutableRequest {
headers: Record<string, string>
body: Record<string, unknown>
generation?: Record<string, unknown>
generation?: Generation
options?: Record<string, unknown>
}
+8 -11
View File
@@ -1,4 +1,5 @@
import { Schema, Types } from "effect"
import { DateTime, Schema } from "effect"
import { DateTimeUtcFromMillis } from "effect/Schema"
import { ProviderV2 } from "./provider"
import { ModelRequest } from "./model-request"
@@ -15,8 +16,8 @@ export type Family = typeof Family.Type
export const Capabilities = Schema.Struct({
tools: Schema.Boolean,
// mime patterns, image, audio, video/*, text/*
input: Schema.String.pipe(Schema.Array, Schema.mutable),
output: Schema.String.pipe(Schema.Array, Schema.mutable),
input: Schema.String.pipe(Schema.Array),
output: Schema.String.pipe(Schema.Array),
})
export type Capabilities = typeof Capabilities.Type
@@ -66,11 +67,11 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
variants: Schema.Struct({
id: VariantID,
...ModelRequest.Request.fields,
}).pipe(Schema.Array, Schema.mutable),
}).pipe(Schema.Array),
time: Schema.Struct({
released: Schema.Finite,
released: DateTimeUtcFromMillis,
}),
cost: Cost.pipe(Schema.Array, Schema.mutable),
cost: Cost.pipe(Schema.Array),
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
enabled: Schema.Boolean,
limit: Schema.Struct({
@@ -102,7 +103,7 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
},
variants: [],
time: {
released: 0,
released: DateTime.makeUnsafe(0),
},
cost: [],
status: "active",
@@ -115,10 +116,6 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
}
}
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
api: ProviderV2.MutableApi<Api>
}
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
const [providerID, ...modelID] = input.split("/")
return {
+27 -23
View File
@@ -20,7 +20,7 @@ export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedErr
export interface EntryPoint {
readonly directory: string
readonly entrypoint?: string
readonly entrypoint: Option.Option<string>
}
export interface Interface {
@@ -34,7 +34,7 @@ export interface Interface {
}[]
},
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
readonly which: (pkg: string, bin?: string) => Effect.Effect<Option.Option<string>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
@@ -47,11 +47,12 @@ export function sanitize(pkg: string) {
}
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
let entrypoint: string | undefined
let entrypoint: Option.Option<string>
try {
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
entrypoint = Option.some(resolved)
} catch {
entrypoint = undefined
entrypoint = Option.none()
}
return {
directory: dir,
@@ -129,7 +130,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 (result.entrypoint) return result
if (Option.isSome(result.entrypoint)) return result
return yield* new InstallFailedError({ add: [pkg], dir })
}
return resolveEntryPoint(first.name, first.path)
@@ -218,24 +219,22 @@ export const layer = Layer.effect(
return Option.some(files[0])
})
return Option.getOrUndefined(
yield* Effect.gen(function* () {
const bin = yield* pick()
if (Option.isSome(bin)) {
return Option.some(path.join(binDir, bin.value))
}
return yield* Effect.gen(function* () {
const bin = yield* pick()
if (Option.isSome(bin)) {
return Option.some(path.join(binDir, bin.value))
}
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
yield* add(pkg)
yield* add(pkg)
const resolved = yield* pick()
if (Option.isNone(resolved)) return Option.none<string>()
return Option.some(path.join(binDir, resolved.value))
}).pipe(
Effect.scoped,
Effect.orElseSucceed(() => Option.none<string>()),
),
const resolved = yield* pick()
if (Option.isNone(resolved)) return Option.none<string>()
return Option.some(path.join(binDir, resolved.value))
}).pipe(
Effect.scoped,
Effect.orElseSucceed(() => Option.none<string>()),
)
})
@@ -262,9 +261,14 @@ export async function install(...args: Parameters<Interface["install"]>) {
}
export async function add(...args: Parameters<Interface["add"]>) {
return runPromise((svc) => svc.add(...args))
const entry = await runPromise((svc) => svc.add(...args))
return {
directory: entry.directory,
entrypoint: Option.getOrUndefined(entry.entrypoint),
}
}
export async function which(...args: Parameters<Interface["which"]>) {
return runPromise((svc) => svc.which(...args))
const resolved = await runPromise((svc) => svc.which(...args))
return Option.getOrUndefined(resolved)
}
+1 -1
View File
@@ -12,5 +12,5 @@ export const Rule = Schema.Struct({
}).annotate({ identifier: "PermissionV2.Rule" })
export type Rule = typeof Rule.Type
export const Ruleset = Schema.mutable(Schema.Array(Rule)).annotate({ identifier: "PermissionV2.Ruleset" })
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" })
export type Ruleset = typeof Ruleset.Type
+20 -43
View File
@@ -7,7 +7,6 @@ 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
@@ -23,7 +22,7 @@ export const Event = {
type HookSpec = {
"catalog.transform": {
input: Catalog.Draft
input: Catalog.Editor
output: {}
}
"aisdk.language": {
@@ -63,16 +62,18 @@ export type HookFunctions = {
export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"]
export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"]
export type Effect<R = never> = Effect.Effect<HookFunctions | void, never, R | Scope.Scope>
export function define<R>(input: { id: ID; effect: Effect.Effect<HookFunctions | void, never, R> }) {
return input
}
export interface Interface {
readonly add: (input: {
id: string
id: ID
effect: Effect.Effect<void | HookFunctions, never, Scope.Scope>
}) => Effect.Effect<void, never, never>
readonly remove: (id: ID) => Effect.Effect<void>
readonly hook: <Name extends keyof Hooks>(
name: Name,
callback: (input: Hooks[Name]) => Effect.Effect<void> | void,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly triggerFor: <Name extends keyof Hooks>(
id: ID,
name: Name,
@@ -96,40 +97,35 @@ 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> | void
}
}[keyof Hooks][] = []
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const locks = KeyedMutex.makeUnsafe<ID>()
const svc = Service.of({
add: Effect.fn("Plugin.add")(function* (input) {
const id = ID.make(input.id)
yield* locks.withLock(id)(
yield* locks.withLock(input.id)(
Effect.gen(function* () {
const existing = hooks.find((item) => item.id === id)
const existing = hooks.find((item) => item.id === input.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": id,
"plugin.id": input.id,
},
}),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)),
)
const next = {
id,
hooks: result ?? {},
scope: childScope,
}
hooks = existing ? hooks.with(hooks.indexOf(existing), next) : [...hooks, next]
yield* events.publish(Event.Added, { id })
hooks = [
...hooks.filter((item) => item.id !== input.id),
{
id: input.id,
hooks: result ?? {},
scope: childScope,
},
]
yield* events.publish(Event.Added, { id: input.id })
}),
)
}),
@@ -164,12 +160,6 @@ 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)
}
@@ -185,19 +175,6 @@ 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
}),
+16 -13
View File
@@ -1,11 +1,12 @@
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 =
@@ -96,10 +97,12 @@ 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 = define({
id: "agent",
effect: Effect.fn(function* (ctx) {
const worktree = ctx.location.directory
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
const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")]
const readonlyExternalDirectory: PermissionV2.Ruleset = [
{ action: "external_directory", resource: "*", effect: "ask" },
@@ -119,8 +122,8 @@ export const Plugin = define({
{ action: "read", resource: "*.env.example", effect: "allow" },
]
yield* ctx.agent.transform((draft) => {
draft.update(AgentV2.defaultID, (item) => {
yield* agent.update((editor) => {
editor.update(AgentV2.defaultID, (item) => {
item.description = "The default agent. Executes tools based on configured permissions."
item.system ??= BUILD_SYSTEM
item.mode = "primary"
@@ -132,7 +135,7 @@ export const Plugin = define({
)
})
draft.update(AgentV2.ID.make("plan"), (item) => {
editor.update(AgentV2.ID.make("plan"), (item) => {
item.description = "Plan mode. Disallows all edit tools."
item.mode = "primary"
item.permissions.push(
@@ -151,14 +154,14 @@ export const Plugin = define({
)
})
draft.update(AgentV2.ID.make("general"), (item) => {
editor.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" }]))
})
draft.update(AgentV2.ID.make("explore"), (item) => {
editor.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
@@ -179,21 +182,21 @@ export const Plugin = define({
)
})
draft.update(AgentV2.ID.make("compaction"), (item) => {
editor.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" }]))
})
draft.update(AgentV2.ID.make("title"), (item) => {
editor.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" }]))
})
draft.update(AgentV2.ID.make("summary"), (item) => {
editor.update(AgentV2.ID.make("summary"), (item) => {
item.mode = "primary"
item.hidden = true
item.system = PROMPT_SUMMARY
+54 -56
View File
@@ -1,7 +1,7 @@
export * as PluginBoot from "./boot"
import type { Plugin as PublicPlugin } from "@opencode-ai/plugin/v2/effect"
import { Context, Deferred, Effect, Layer } from "effect"
import { Credential } from "../credential"
import { Integration } from "../integration"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
@@ -13,7 +13,6 @@ 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"
@@ -27,13 +26,29 @@ import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SkillV2 } from "../skill"
import { Reference } from "../reference"
import { State } from "../state"
import { PluginHost } from "./host"
type InternalPlugin = PublicPlugin<any>
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
>
}
export interface Interface {
readonly add: (plugin: PublicPlugin<any>) => Effect.Effect<void>
readonly wait: () => Effect.Effect<void>
}
@@ -45,7 +60,8 @@ export const layer = Layer.effect(
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const plugin = yield* PluginV2.Service
const integration = yield* Integration.Service
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const agents = yield* AgentV2.Service
const config = yield* Config.Service
const location = yield* Location.Service
@@ -53,54 +69,47 @@ 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 reference = yield* Reference.Service
const host = yield* PluginHost.make()
const references = yield* Reference.Service
const done = yield* Deferred.make<void>()
const add = Effect.fn("PluginBoot.add")(function* (input: InternalPlugin) {
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
yield* plugin.add({
id: input.id,
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),
),
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),
),
})
})
const boot = Effect.gen(function* () {
yield* State.batch(
Effect.gen(function* () {
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
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)
}
}),
)
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)
}).pipe(Effect.withSpan("PluginBoot.boot"))
yield* boot.pipe(
@@ -110,22 +119,12 @@ 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),
})
}),
)
export const locationLayer = layer.pipe(
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(Catalog.locationLayer),
Layer.provideMerge(CommandV2.locationLayer),
@@ -133,5 +132,4 @@ export const locationLayer = layer.pipe(
Layer.provideMerge(AgentV2.locationLayer),
Layer.provideMerge(SkillV2.locationLayer),
Layer.provideMerge(Reference.locationLayer),
Layer.provideMerge(FileSystem.locationLayer),
)
+15 -9
View File
@@ -1,20 +1,26 @@
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 = 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)
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)
command.description = "guided AGENTS.md setup"
})
draft.update("review", (command) => {
command.template = PROMPT_REVIEW.replace("${path}", ctx.location.project.directory)
editor.update("review", (command) => {
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
command.subtask = true
})
-236
View File
@@ -1,236 +0,0 @@
export * as PluginHost from "./host"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import type { PluginHost as Interface } from "@opencode-ai/plugin/v2/effect"
import type { Event as SDKEvent, ModelV2Info } from "@opencode-ai/sdk/v2/types"
import { Effect, Schema, Stream } from "effect"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
import { EventV2 } from "../event"
import { FileSystem } from "../filesystem"
import { Global } from "../global"
import { Integration } from "../integration"
import { Location } from "../location"
import { ModelV2 } from "../model"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider"
import { Reference } from "../reference"
import { SkillV2 } from "../skill"
type EventMap = { [Item in SDKEvent as Item["type"]]: Item }
type SDKHook = (event: {
readonly model: ModelV2Info
readonly package: string
readonly options: Record<string, any>
sdk?: any
}) => Effect.Effect<void> | void
type LanguageHook = (event: {
readonly model: ModelV2Info
readonly sdk: any
readonly options: Record<string, any>
language?: LanguageModelV3
}) => Effect.Effect<void> | void
export const make = Effect.fn("PluginHost.make")(function* () {
const agents = yield* AgentV2.Service
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const events = yield* EventV2.Service
const filesystem = yield* FileSystem.Service
const global = yield* Global.Service
const integration = yield* Integration.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const plugin = yield* PluginV2.Service
const reference = yield* Reference.Service
const skill = yield* SkillV2.Service
return {
agent: {
get: (id) => agents.get(AgentV2.ID.make(id)),
default: agents.default,
list: agents.all,
rebuild: agents.rebuild,
transform: (callback) =>
agents.transform((draft) =>
callback({
list: draft.list,
get: (id) => draft.get(AgentV2.ID.make(id)),
default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)),
update: (id, update) => draft.update(AgentV2.ID.make(id), update),
remove: (id) => draft.remove(AgentV2.ID.make(id)),
}),
),
},
aisdk: {
hook: (name, callback) => {
if (name === "sdk") {
const run = callback as SDKHook
return plugin.hook("aisdk.sdk", (event) => {
const output = {
model: 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 LanguageHook
return plugin.hook("aisdk.language", (event) => {
const output = {
model: 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) => catalog.provider.get(ProviderV2.ID.make(id)),
list: catalog.provider.all,
available: catalog.provider.available,
},
model: {
get: (providerID, modelID) => catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
list: catalog.model.all,
available: catalog.model.available,
default: catalog.model.default,
small: (providerID) => catalog.model.small(ProviderV2.ID.make(providerID)),
},
rebuild: catalog.rebuild,
transform: (callback) =>
catalog.transform((draft) =>
callback({
provider: {
list: draft.provider.list,
get: (id) => draft.provider.get(ProviderV2.ID.make(id)),
update: (id, update) => draft.provider.update(ProviderV2.ID.make(id), update),
remove: (id) => draft.provider.remove(ProviderV2.ID.make(id)),
},
model: {
get: (providerID, modelID) => draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
update: (providerID, modelID, update) =>
draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), update),
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)),
},
},
}),
),
},
command: {
get: commands.get,
list: commands.list,
rebuild: commands.rebuild,
transform: commands.transform,
},
event: {
subscribe: <Type extends keyof EventMap>(type: Type): Stream.Stream<EventMap[Type]> =>
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<unknown, unknown, never, never>)
return events.subscribe(definition).pipe(
Stream.map(
(event) =>
({
id: event.id,
type: event.type,
properties: encode(event.data),
}) as unknown as EventMap[Type],
),
)
}),
),
},
filesystem: {
read: (input) => filesystem.read(Schema.decodeUnknownSync(FileSystem.ReadInput)(input)),
list: (input) => filesystem.list(Schema.decodeUnknownSync(FileSystem.ListInput)(input ?? {})),
find: (input) => filesystem.find(Schema.decodeUnknownSync(FileSystem.FindInput)(input)),
glob: (input) => filesystem.glob(Schema.decodeUnknownSync(FileSystem.GlobInput)(input)),
},
integration: {
get: (id) => integration.get(Integration.ID.make(id)),
list: integration.list,
rebuild: integration.rebuild,
transform: (callback) =>
integration.transform((draft) =>
callback({
list: draft.list,
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)),
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)),
},
}),
),
},
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,
rebuild: reference.rebuild,
transform: (callback) =>
reference.transform((draft) =>
callback({
add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
remove: draft.remove,
list: draft.list,
}),
),
},
skill: {
sources: skill.sources,
list: skill.list,
rebuild: skill.rebuild,
transform: (callback) =>
skill.transform((draft) =>
callback({
source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)),
list: draft.list,
}),
),
},
} satisfies Interface
})
+26 -19
View File
@@ -1,13 +1,16 @@
import { define } from "@opencode-ai/plugin/v2/effect"
import { Effect, Stream } from "effect"
import { DateTime, Effect, Scope, Stream } from "effect"
import { Catalog } from "../catalog"
import { Integration } from "../integration"
import { EventV2 } from "../event"
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 Number.isFinite(time) ? time : 0
return DateTime.makeUnsafe(Number.isFinite(time) ? time : 0)
}
function cost(input: ModelsDev.Model["cost"]) {
@@ -48,16 +51,22 @@ function variants(model: ModelsDev.Model, packageName?: string) {
})
}
export const ModelsDevPlugin = define({
id: "models-dev",
effect: Effect.fn(function* (ctx) {
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
const modelsDev = yield* ModelsDev.Service
yield* ctx.integration.transform(
Effect.fn(function* (integrations) {
const data = yield* modelsDev.get()
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) => {
for (const item of Object.values(data)) {
if (item.env.length === 0) continue
const integrationID = item.id
const integrationID = Integration.ID.make(item.id)
integrations.update(integrationID, (integration) => (integration.name = item.name))
integrations.method.update({
integrationID,
@@ -68,11 +77,8 @@ export const ModelsDevPlugin = define({
method: { type: "env", names: [...item.env] },
})
}
}),
)
yield* ctx.catalog.transform(
Effect.fn(function* (catalog) {
const data = yield* modelsDev.get()
})
yield* transform((catalog) => {
for (const item of Object.values(data)) {
const providerID = ProviderV2.ID.make(item.id)
catalog.provider.update(providerID, (provider) => {
@@ -126,10 +132,11 @@ export const ModelsDevPlugin = define({
})
}
}
}),
)
yield* ctx.event.subscribe("models-dev.refreshed").pipe(
Stream.runForEach(() => ctx.integration.rebuild().pipe(Effect.andThen(ctx.catalog.rebuild()))),
})
})
yield* refresh()
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.runForEach(() => refresh()),
Effect.forkScoped({ startImmediately: true }),
)
}),
+7 -8
View File
@@ -1,16 +1,15 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const AlibabaPlugin = define({
id: "alibaba",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
export const AlibabaPlugin = PluginV2.define({
id: PluginV2.ID.make("alibaba"),
effect: Effect.gen(function* () {
return {
"aisdk.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)
}),
)
}
}),
})
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
type MantleSDK = {
@@ -59,11 +59,11 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
return sdk.responses(modelID)
}
export const AmazonBedrockPlugin = define({
id: "amazon-bedrock",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
export const AmazonBedrockPlugin = PluginV2.define({
id: PluginV2.ID.make("amazon-bedrock"),
effect: Effect.gen(function* () {
return {
"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,10 +77,7 @@ export const AmazonBedrockPlugin = define({
})
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
"aisdk.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
@@ -111,10 +108,7 @@ export const AmazonBedrockPlugin = define({
const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock"))
evt.sdk = mod.createAmazonBedrock(options)
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
"aisdk.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)
@@ -123,6 +117,6 @@ export const AmazonBedrockPlugin = 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))
}),
)
}
}),
})
+8 -11
View File
@@ -1,11 +1,11 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const AnthropicPlugin = define({
id: "anthropic",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
export const AnthropicPlugin = PluginV2.define({
id: PluginV2.ID.make("anthropic"),
effect: Effect.gen(function* () {
return {
"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,14 +15,11 @@ export const AnthropicPlugin = define({
})
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
"aisdk.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)
}),
)
}
}),
})
+16 -25
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
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 = define({
id: "azure",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
export const AzurePlugin = PluginV2.define({
id: PluginV2.ID.make("azure"),
effect: Effect.gen(function* () {
return {
"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,10 +27,7 @@ export const AzurePlugin = define({
})
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/azure") return
if (evt.model.providerID === ProviderV2.ID.azure) {
if (
@@ -46,22 +43,19 @@ export const AzurePlugin = define({
const mod = yield* Effect.promise(() => import("@ai-sdk/azure"))
evt.sdk = mod.createAzure(evt.options)
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
"aisdk.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 = define({
id: "azure-cognitive-services",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
export const AzureCognitiveServicesPlugin = PluginV2.define({
id: PluginV2.ID.make("azure-cognitive-services"),
effect: Effect.gen(function* () {
return {
"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()) {
@@ -73,13 +67,10 @@ export const AzureCognitiveServicesPlugin = define({
})
}
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
"aisdk.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))
}),
)
}
}),
})
+10 -13
View File
@@ -1,27 +1,24 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
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()) {
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()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/cerebras") continue
evt.provider.update(item.provider.id, (provider) => {
ctx.provider.update(item.provider.id, (provider) => {
provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
})
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
"aisdk.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)
}),
)
}
}),
})
@@ -1,14 +1,13 @@
import os from "os"
import { InstallationVersion } from "../../installation/version"
import { Effect, Option, Schema } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const CloudflareAIGatewayPlugin = define({
id: "cloudflare-ai-gateway",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
export const CloudflareAIGatewayPlugin = PluginV2.define({
id: PluginV2.ID.make("cloudflare-ai-gateway"),
effect: Effect.gen(function* () {
return {
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "ai-gateway-provider") return
if (evt.options.baseURL) return
@@ -32,7 +31,7 @@ export const CloudflareAIGatewayPlugin = define({
},
}
}),
)
}
}),
})
@@ -1,16 +1,16 @@
import os from "os"
import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
export const CloudflareWorkersAIPlugin = define({
id: "cloudflare-workers-ai",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
export const CloudflareWorkersAIPlugin = PluginV2.define({
id: PluginV2.ID.make("cloudflare-workers-ai"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
const item = evt.provider.get(providerID)
if (!item) return
evt.provider.update(item.provider.id, (provider) => {
@@ -20,10 +20,7 @@ export const CloudflareWorkersAIPlugin = define({
if (accountId) provider.api.url = workersEndpoint(accountId)
})
}),
)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return
if (evt.package !== "@ai-sdk/openai-compatible") return
@@ -37,14 +34,11 @@ export const CloudflareWorkersAIPlugin = define({
}) as any,
)
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
"aisdk.language": Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return
evt.language = evt.sdk.languageModel(evt.model.api.id)
}),
)
}
}),
})
+7 -8
View File
@@ -1,16 +1,15 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const CoherePlugin = define({
id: "cohere",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
export const CoherePlugin = PluginV2.define({
id: PluginV2.ID.make("cohere"),
effect: Effect.gen(function* () {
return {
"aisdk.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)
}),
)
}
}),
})
@@ -1,16 +1,15 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const DeepInfraPlugin = define({
id: "deepinfra",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
export const DeepInfraPlugin = PluginV2.define({
id: PluginV2.ID.make("deepinfra"),
effect: Effect.gen(function* () {
return {
"aisdk.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)
}),
)
}
}),
})
+11 -10
View File
@@ -1,18 +1,19 @@
import { Effect } from "effect"
import { Npm } from "../../npm"
import { Effect, Option } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const DynamicProviderPlugin = define({
id: "dynamic-provider",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
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) {
if (evt.sdk) return
const installedPath = evt.package.startsWith("file://")
? evt.package
: (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint
: Option.getOrUndefined((yield* 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 () => {
@@ -25,6 +26,6 @@ export const DynamicProviderPlugin = define({
evt.sdk = mod[match](evt.options)
}),
)
}
}),
})
+7 -8
View File
@@ -1,16 +1,15 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const GatewayPlugin = define({
id: "gateway",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
export const GatewayPlugin = PluginV2.define({
id: PluginV2.ID.make("gateway"),
effect: Effect.gen(function* () {
return {
"aisdk.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)
}),
)
}
}),
})
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
function shouldUseResponses(modelID: string) {
@@ -11,31 +11,16 @@ function shouldUseResponses(modelID: string) {
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
}
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) {
export const GithubCopilotPlugin = PluginV2.define({
id: PluginV2.ID.make("github-copilot"),
effect: Effect.gen(function* () {
return {
"aisdk.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)
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
"aisdk.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)
@@ -45,6 +30,15 @@ export const GithubCopilotPlugin = 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
})
}),
}
}),
})
+8 -12
View File
@@ -1,15 +1,14 @@
import os from "os"
import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const GitLabPlugin = define({
id: "gitlab",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
export const GitLabPlugin = PluginV2.define({
id: PluginV2.ID.make("gitlab"),
effect: Effect.gen(function* () {
return {
"aisdk.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({
@@ -31,10 +30,7 @@ export const GitLabPlugin = define({
},
})
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
"aisdk.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 : {}
@@ -62,6 +58,6 @@ export const GitLabPlugin = define({
featureFlags,
})
}),
)
}
}),
})
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
function resolveProject(options: Record<string, any>) {
@@ -54,11 +54,11 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
}
}
export const GoogleVertexPlugin = define({
id: "google-vertex",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
export const GoogleVertexPlugin = PluginV2.define({
id: PluginV2.ID.make("google-vertex"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (
@@ -83,10 +83,7 @@ export const GoogleVertexPlugin = define({
})
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
"aisdk.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
@@ -103,22 +100,19 @@ export const GoogleVertexPlugin = define({
location,
})
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
"aisdk.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 = define({
id: "google-vertex-anthropic",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
export const GoogleVertexAnthropicPlugin = PluginV2.define({
id: PluginV2.ID.make("google-vertex-anthropic"),
effect: Effect.gen(function* () {
return {
"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
@@ -138,10 +132,7 @@ export const GoogleVertexAnthropicPlugin = define({
})
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
"aisdk.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 =
@@ -165,13 +156,10 @@ export const GoogleVertexAnthropicPlugin = define({
: {}),
})
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
"aisdk.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())
}),
)
}
}),
})
+7 -8
View File
@@ -1,16 +1,15 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const GooglePlugin = define({
id: "google",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
export const GooglePlugin = PluginV2.define({
id: PluginV2.ID.make("google"),
effect: Effect.gen(function* () {
return {
"aisdk.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)
}),
)
}
}),
})
+7 -8
View File
@@ -1,16 +1,15 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const GroqPlugin = define({
id: "groq",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
export const GroqPlugin = PluginV2.define({
id: PluginV2.ID.make("groq"),
effect: Effect.gen(function* () {
return {
"aisdk.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)
}),
)
}
}),
})
+7 -7
View File
@@ -1,11 +1,11 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const KiloPlugin = define({
id: "kilo",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
export const KiloPlugin = PluginV2.define({
id: PluginV2.ID.make("kilo"),
effect: Effect.gen(function* () {
return {
"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 = define({
})
}
}),
)
}
}),
})
@@ -1,17 +1,19 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { Integration } from "../../integration"
import { PluginV2 } from "../../plugin"
export const LLMGatewayPlugin = define({
id: "llmgateway",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
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) {
for (const item of evt.provider.list()) {
if (item.provider.disabled) continue
if (!(yield* integrations.get(Integration.ID.make(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
if (!(yield* ctx.integration.get(item.provider.id))) continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
@@ -19,6 +21,6 @@ export const LLMGatewayPlugin = define({
})
}
}),
)
}
}),
})
+7 -8
View File
@@ -1,16 +1,15 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const MistralPlugin = define({
id: "mistral",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
export const MistralPlugin = PluginV2.define({
id: PluginV2.ID.make("mistral"),
effect: Effect.gen(function* () {
return {
"aisdk.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)
}),
)
}
}),
})
+7 -7
View File
@@ -1,11 +1,11 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const NvidiaPlugin = define({
id: "nvidia",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
export const NvidiaPlugin = PluginV2.define({
id: PluginV2.ID.make("nvidia"),
effect: Effect.gen(function* () {
return {
"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 = define({
})
}
}),
)
}
}),
})
@@ -1,18 +1,17 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const OpenAICompatiblePlugin = define({
id: "openai-compatible",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
export const OpenAICompatiblePlugin = PluginV2.define({
id: PluginV2.ID.make("openai-compatible"),
effect: Effect.gen(function* () {
return {
"aisdk.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)
}),
)
}
}),
})
+19 -25
View File
@@ -1,20 +1,29 @@
import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
import { Integration } from "../../integration"
import { browser, headless } from "./openai-auth"
export const OpenAIPlugin = define({
id: "openai",
effect: Effect.fn(function* (ctx) {
export const OpenAIPlugin = PluginV2.define({
id: PluginV2.ID.make("openai"),
effect: Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* integrations.transform((draft) => {
draft.method.update(browser)
draft.method.update(headless)
yield* integrations.update((editor) => {
editor.method.update(browser)
editor.method.update(headless)
})
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
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) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai") continue
@@ -26,21 +35,6 @@ export const OpenAIPlugin = 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)
}),
)
}
}),
})
+10 -8
View File
@@ -1,16 +1,18 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { Integration } from "../../integration"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const OpencodePlugin = define({
id: "opencode",
effect: Effect.fn(function* (ctx) {
export const OpencodePlugin = PluginV2.define({
id: PluginV2.ID.make("opencode"),
effect: Effect.gen(function* () {
const integrations = yield* Integration.Service
let hasKey = false
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
return {
"catalog.transform": Effect.fn(function* (evt) {
const item = evt.provider.get(ProviderV2.ID.opencode)
if (!item) return
const integration = yield* ctx.integration.get(item.provider.id)
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
hasKey = Boolean(
process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey,
)
@@ -25,6 +27,6 @@ export const OpencodePlugin = define({
})
}
}),
)
}
}),
})
@@ -1,12 +1,12 @@
import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const OpenRouterPlugin = define({
id: "openrouter",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
export const OpenRouterPlugin = PluginV2.define({
id: PluginV2.ID.make("openrouter"),
effect: Effect.gen(function* () {
return {
"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,14 +24,11 @@ export const OpenRouterPlugin = define({
}
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
"aisdk.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)
}),
)
}
}),
})
@@ -1,16 +1,15 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const PerplexityPlugin = define({
id: "perplexity",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
export const PerplexityPlugin = PluginV2.define({
id: PluginV2.ID.make("perplexity"),
effect: Effect.gen(function* () {
return {
"aisdk.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)
}),
)
}
}),
})
@@ -1,14 +1,15 @@
import { Effect } from "effect"
import { Npm } from "../../npm"
import { Effect, Option } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const SapAICorePlugin = define({
id: "sap-ai-core",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
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) {
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
const serviceKey =
process.env.AICORE_SERVICE_KEY ??
@@ -17,7 +18,7 @@ export const SapAICorePlugin = define({
const installedPath = evt.package.startsWith("file://")
? evt.package
: (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint
: Option.getOrUndefined((yield* 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 () => {
@@ -34,13 +35,10 @@ export const SapAICorePlugin = define({
: {},
)
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
"aisdk.language": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
evt.language = evt.sdk(evt.model.api.id)
}),
)
}
}),
})
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
@@ -64,12 +64,11 @@ export function cortexFetch(upstream: FetchLike = fetch) {
}
}
export const SnowflakeCortexPlugin = define({
id: "snowflake-cortex",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
export const SnowflakeCortexPlugin = PluginV2.define({
id: PluginV2.ID.make("snowflake-cortex"),
effect: Effect.gen(function* () {
return {
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
const token =
process.env.SNOWFLAKE_CORTEX_TOKEN ??
@@ -85,6 +84,6 @@ export const SnowflakeCortexPlugin = define({
fetch: cortexFetch(upstream) as typeof fetch,
} as any)
}),
)
}
}),
})
@@ -1,16 +1,15 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const TogetherAIPlugin = define({
id: "togetherai",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
export const TogetherAIPlugin = PluginV2.define({
id: PluginV2.ID.make("togetherai"),
effect: Effect.gen(function* () {
return {
"aisdk.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)
}),
)
}
}),
})
+7 -8
View File
@@ -1,16 +1,15 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const VenicePlugin = define({
id: "venice",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
export const VenicePlugin = PluginV2.define({
id: PluginV2.ID.make("venice"),
effect: Effect.gen(function* () {
return {
"aisdk.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)
}),
)
}
}),
})
+8 -11
View File
@@ -1,11 +1,11 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const VercelPlugin = define({
id: "vercel",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
export const VercelPlugin = PluginV2.define({
id: PluginV2.ID.make("vercel"),
effect: Effect.gen(function* () {
return {
"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,14 +15,11 @@ export const VercelPlugin = define({
})
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
"aisdk.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)
}),
)
}
}),
})
+8 -12
View File
@@ -1,24 +1,20 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const XAIPlugin = define({
id: "xai",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
export const XAIPlugin = PluginV2.define({
id: PluginV2.ID.make("xai"),
effect: Effect.gen(function* () {
return {
"aisdk.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)
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
"aisdk.language": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("xai")) return
evt.language = evt.sdk.responses(evt.model.api.id)
}),
)
}
}),
})
+7 -7
View File
@@ -1,11 +1,11 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { PluginV2 } from "../../plugin"
export const ZenmuxPlugin = define({
id: "zenmux",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
export const ZenmuxPlugin = PluginV2.define({
id: PluginV2.ID.make("zenmux"),
effect: Effect.gen(function* () {
return {
"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 = define({
})
}
}),
)
}
}),
})
+9 -6
View File
@@ -2,19 +2,22 @@
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 = define({
id: "skill",
effect: Effect.fn(function* (ctx) {
yield* ctx.skill.transform((draft) => {
draft.source(
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(
new SkillV2.EmbeddedSource({
type: "embedded",
skill: new SkillV2.Info({
+1 -6
View File
@@ -1,7 +1,7 @@
export * as ProviderV2 from "./provider"
import { withStatics } from "./schema"
import { Schema, Types } from "effect"
import { Schema } from "effect"
export const ID = Schema.String.pipe(
Schema.brand("ProviderV2.ID"),
@@ -37,9 +37,6 @@ 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 = Api> = T extends Api
? Omit<Types.DeepMutable<T>, "settings"> & (undefined extends T["settings"] ? { settings?: any } : { settings: any })
: never
export const Request = Schema.Struct({
headers: Schema.Record(Schema.String, Schema.String),
@@ -69,5 +66,3 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
})
}
}
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & { api: MutableApi }
+5 -2
View File
@@ -1,6 +1,7 @@
export * as Session from "./session"
import { Effect, Schema, Stream } from "effect"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { SessionV2 } from "../session"
import { MessageDecodeError } from "../session/error"
@@ -33,7 +34,9 @@ export type Delivery = SessionInput.Delivery
export const ListInput = SessionV2.ListInput
export type ListInput = SessionV2.ListInput
export type Event = SessionEvent.DurableEvent
export const EventCursor = EventV2.Cursor
export type EventCursor = EventV2.Cursor
export type Event = EventV2.CursorEvent<SessionEvent.DurableEvent>
export const NotFoundError = SessionV2.NotFoundError
export type NotFoundError = SessionV2.NotFoundError
@@ -96,7 +99,7 @@ export interface MessageInput {
export interface EventsInput {
readonly sessionID: ID
readonly after?: number
readonly after?: EventCursor
}
export interface Interface {
+11 -10
View File
@@ -1,6 +1,7 @@
export * as Reference from "./reference"
import { Context, Effect, Layer, Schema, Scope, Types } from "effect"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft } from "immer"
import { Global } from "./global"
import { EventV2 } from "./event"
import { Repository } from "./repository"
@@ -39,16 +40,17 @@ export class Info extends Schema.Class<Info>("Reference.Info")({
}) {}
type Data = {
sources: Map<string, Types.DeepMutable<Source>>
sources: Map<string, Source>
}
type Draft = {
type Editor = {
add(name: string, source: Source): void
remove(name: string): void
list(): readonly [string, Source][]
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly list: () => Effect.Effect<Info[]>
}
@@ -62,18 +64,18 @@ export const layer = Layer.effect(
const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope
const materialized = new Map<string, Info>()
const state = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
initial: () => ({ sources: new Map() }),
draft: (draft) => ({
add: (name, source) => draft.sources.set(name, source as Types.DeepMutable<Source>),
editor: (draft) => ({
add: (name, source) => draft.sources.set(name, castDraft(source)),
remove: (name) => draft.sources.delete(name),
list: () => Array.from(draft.sources.entries()) as [string, Source][],
}),
finalize: (draft) =>
finalize: (editor) =>
Effect.gen(function* () {
materialized.clear()
const seen = new Map<string, string | undefined>()
for (const [name, source] of draft.list()) {
for (const [name, source] of editor.list()) {
if (source.type === "local") {
materialized.set(
name,
@@ -126,7 +128,6 @@ 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())
}),
+10 -6
View File
@@ -124,8 +124,8 @@ export interface Interface {
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
readonly events: (input: {
sessionID: SessionSchema.ID
after?: number
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
after?: EventV2.Cursor
}) => Stream.Stream<EventV2.CursorEvent<SessionEvent.DurableEvent>, NotFoundError>
readonly switchAgent: (input: {
sessionID: SessionSchema.ID
agent: string
@@ -339,8 +339,12 @@ export const layer = Layer.effect(
Stream.unwrap(
result
.get(input.sessionID)
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
.pipe(Effect.as(events.aggregateEvents({ aggregateID: input.sessionID, after: input.after }))),
).pipe(
Stream.filter((event): event is EventV2.CursorEvent<SessionEvent.DurableEvent> =>
isDurableSessionEvent(event.event),
),
),
prompt: Effect.fn("V2Session.prompt")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
@@ -409,9 +413,9 @@ export const layer = Layer.effect(
sessionID,
timestamp: yield* DateTime.now,
})
if (event.durable === undefined)
if (event.seq === undefined)
return yield* Effect.die("Interrupt request event is missing aggregate sequence")
yield* execution.interrupt(sessionID, event.durable.seq)
yield* execution.interrupt(sessionID, event.seq)
}),
),
),
+3 -3
View File
@@ -27,13 +27,13 @@ const Base = {
}
const options = {
durable: {
sync: {
aggregate: "sessionID",
version: 1,
},
} as const
const stepSettlementOptions = {
durable: {
sync: {
aggregate: "sessionID",
version: 2,
},
@@ -456,7 +456,7 @@ export namespace Compaction {
export const Ended = EventV2.define({
type: "session.next.compaction.ended",
durable: { aggregate: "sessionID", version: 2 },
sync: { aggregate: "sessionID", version: 2 },
schema: {
...Base,
messageID: SessionMessageID.ID,
+40 -2
View File
@@ -74,11 +74,11 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
})
.pipe(
Effect.flatMap((event) =>
event.durable === undefined
event.seq === undefined
? Effect.die("Prompt admission event is missing aggregate sequence")
: Effect.succeed(
new Admitted({
admittedSeq: event.durable.seq,
admittedSeq: event.seq,
id: input.id,
sessionID: input.sessionID,
prompt: input.prompt,
@@ -117,6 +117,13 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
readonly timeCreated: DateTime.Utc
},
) {
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, input.id))
.get()
.pipe(Effect.orDie)
if (message) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const stored = yield* db
.insert(SessionInputTable)
.values({
@@ -201,6 +208,37 @@ const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionS
input.sessionID === expected.sessionID &&
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(function* (
db: DatabaseService,
event: EventV2.Payload,
) {
if (
Schema.is(SessionEvent.PromptLifecycle.Admitted)(event) ||
Schema.is(SessionEvent.PromptLifecycle.Promoted)(event)
)
return
const id = reservedID(event)
if (id === undefined) return
const admitted = yield* db
.select({ id: SessionInputTable.id })
.from(SessionInputTable)
.where(eq(SessionInputTable.id, id))
.get()
.pipe(Effect.orDie)
if (admitted === undefined) return
return yield* Effect.die(new LifecycleConflict({ id }))
})
const reservedID = (event: EventV2.Payload) => {
if (Schema.is(SessionEvent.Step.Started)(event)) return event.data.assistantMessageID
if (Schema.is(SessionEvent.AgentSwitched)(event)) return event.data.messageID
if (Schema.is(SessionEvent.ModelSwitched)(event)) return event.data.messageID
if (Schema.is(SessionEvent.Prompted)(event)) return event.data.messageID
if (Schema.is(SessionEvent.Synthetic)(event)) return event.data.messageID
if (Schema.is(SessionEvent.Shell.Started)(event)) return event.data.messageID
if (Schema.is(SessionEvent.Compaction.Started)(event)) return event.data.messageID
}
export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* (
db: DatabaseService,
input: {
+27 -18
View File
@@ -115,7 +115,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type })
const updateMessage = (message: SessionMessage.Message) => {
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
return db
@@ -192,7 +192,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
}
function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) {
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
return db
@@ -201,7 +201,7 @@ function insertMessage(db: DatabaseService, event: SessionEvent.Event, message:
id: SessionMessage.ID.make(id),
session_id: event.data.sessionID,
type,
seq: event.durable.seq,
seq: event.seq,
time_created: DateTime.toEpochMillis(message.time.created),
data,
})
@@ -213,6 +213,7 @@ export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* events.beforeCommit((event) => SessionInput.guardReservedID(db, event))
yield* events.project(SessionV1.Event.Created, (event) =>
Effect.gen(function* () {
const stored = yield* db
@@ -330,7 +331,7 @@ export const layer = Layer.effectDiscard(
}),
)
yield* events.project(SessionEvent.AgentSwitched, (event) => {
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
return db
.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
@@ -339,7 +340,7 @@ export const layer = Layer.effectDiscard(
.pipe(
Effect.orDie,
Effect.andThen(run(db, event)),
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq)),
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
)
})
yield* events.project(SessionEvent.ModelSwitched, (event) =>
@@ -351,8 +352,9 @@ export const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie)
yield* run(db, event)
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq)
if (event.seq === undefined)
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)
}),
)
yield* events.project(SessionEvent.Prompted, (event) =>
@@ -366,22 +368,24 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
if (existing) return yield* Effect.die(new PromptAlreadyProjected())
yield* run(db, event)
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
if (event.seq === undefined)
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
yield* SessionInput.projectLegacyPrompted(db, {
id: messageID,
sessionID: event.data.sessionID,
prompt: event.data.prompt,
delivery: event.data.delivery,
timeCreated: event.data.timestamp,
promotedSeq: event.durable.seq,
promotedSeq: event.seq,
})
}),
)
yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
if (event.seq === undefined)
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
yield* SessionInput.projectAdmitted(db, {
admittedSeq: event.durable.seq,
admittedSeq: event.seq,
id: event.data.messageID,
sessionID: event.data.sessionID,
prompt: event.data.prompt,
@@ -392,7 +396,8 @@ export const layer = Layer.effectDiscard(
)
yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
if (event.seq === undefined)
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
yield* insertMessage(
db,
event,
@@ -401,14 +406,18 @@ export const layer = Layer.effectDiscard(
sessionID: event.data.sessionID,
prompt: event.data.prompt,
timeCreated: event.data.timeCreated,
promotedSeq: event.durable.seq,
promotedSeq: event.seq,
}),
)
}),
)
yield* events.project(SessionEvent.InterruptRequested, () => Effect.void)
// TODO: Reconstruct context epoch replacement state during replay without adding replay state to every EventV2 payload.
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
yield* events.project(SessionEvent.ContextUpdated, (event) => {
if (!event.replay || event.seq === undefined) return run(db, event)
return run(db, event).pipe(
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
)
})
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
@@ -427,9 +436,9 @@ export const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => {
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
if (event.durable.version === 1) return Effect.void
const seq = event.durable.seq
if (event.version === 1) return Effect.void
const seq = event.seq
if (seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
return Effect.gen(function* () {
yield* run(db, event)
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq)
+7 -5
View File
@@ -33,7 +33,11 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiE
},
) {}
export type Error = ModelNotSelectedError | UnsupportedApiError
export type Error =
| Catalog.ProviderNotFoundError
| Catalog.ModelNotFoundError
| ModelNotSelectedError
| UnsupportedApiError
export interface Interface {
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
@@ -145,12 +149,10 @@ export const locationLayer = Layer.effect(
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
// Location plugins populate and filter the catalog asynchronously during layer startup.
yield* boot.wait()
const defaultModel = session.model ? undefined : yield* catalog.model.default()
const selected = session.model
? yield* catalog.model.get(session.model.providerID, session.model.id)
: defaultModel && supported(defaultModel)
? defaultModel
: (yield* catalog.model.available()).find(supported)
: (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
(yield* catalog.model.available()).find(supported))
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
return yield* fromCatalogModel(
+9 -8
View File
@@ -1,7 +1,8 @@
export * as SkillV2 from "./skill"
import path from "path"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { castDraft } from "immer"
import { AgentV2 } from "./agent"
import { ConfigMarkdown } from "./config/markdown"
import { FSUtil } from "./fs-util"
@@ -64,15 +65,16 @@ const Frontmatter = Schema.Struct({
const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter)
export type Data = {
sources: Types.DeepMutable<Source>[]
sources: Source[]
}
export type Draft = {
export type Editor = {
source: (source: Source) => void
list: () => readonly Source[]
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly sources: () => Effect.Effect<Source[]>
readonly list: () => Effect.Effect<Info[]>
}
@@ -85,12 +87,12 @@ export const layer = Layer.effect(
const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service
const state = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
initial: () => ({ sources: [] }),
draft: (draft) => ({
editor: (draft) => ({
source: (source) => {
if (draft.sources.some((item) => Source.equals(item, source))) return
draft.sources.push(source as Types.DeepMutable<Source>)
draft.sources.push(castDraft(source))
},
list: () => draft.sources as Source[],
}),
@@ -148,7 +150,6 @@ export const layer = Layer.effect(
return Service.of({
transform: state.transform,
rebuild: state.rebuild,
sources: Effect.fn("SkillV2.sources")(function* () {
return state.get().sources
}),
+72 -88
View File
@@ -1,128 +1,112 @@
export * as State from "./state"
import { Context, Effect, Scope, Semaphore } from "effect"
import { Effect, Scope, Semaphore } from "effect"
import type { Draft, Objectish } from "immer"
/**
* A replayable transform applied to a draft during rebuild.
* A replayable transform applied to an editor during rebuild.
*
* Domain drafts expose readable and writable state while preserving concise
* plugin/config code. Transforms may perform Effects before returning.
* Transforms are intentionally synchronous and mutation-shaped: domain editors
* hide the draft representation while preserving concise plugin/config code.
*/
type TransformCallback<DraftApi> = (draft: DraftApi) => Effect.Effect<void> | void
export type MakeDraft<State, DraftApi> = (state: State) => DraftApi
export type Transform<Editor> = (editor: Editor) => void
export type MakeEditor<State extends Objectish, Editor> = (draft: Draft<State>) => Editor
export interface Registration {
readonly dispose: Effect.Effect<void>
}
export type Transform<DraftApi> = (
transform: TransformCallback<DraftApi>,
) => Effect.Effect<Registration, never, Scope.Scope>
export type Rebuild = () => Effect.Effect<void>
export interface Transformable<DraftApi> {
readonly transform: Transform<DraftApi>
readonly rebuild: Rebuild
}
const CurrentBatch = Context.Reference<Set<Rebuild> | undefined>("@opencode/State/CurrentBatch", {
defaultValue: () => undefined,
})
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const current = yield* CurrentBatch
if (current) return yield* effect
const rebuilds = new Set<Rebuild>()
const result = yield* effect.pipe(Effect.provideService(CurrentBatch, rebuilds))
yield* Effect.forEach(rebuilds, (rebuild) => rebuild(), { discard: true })
return result
})
}
export interface Options<State, DraftApi> {
export interface Options<State extends Objectish, Editor> {
/** Creates the base value for initial state and every scoped-transform rebuild. */
readonly initial: () => State
/** Wraps mutable state in a domain-specific draft API. */
readonly draft: MakeDraft<State, DraftApi>
/** Runs after all active transforms and before the rebuilt state becomes visible. */
readonly finalize?: (draft: DraftApi) => Effect.Effect<void>
/** Wraps the mutable draft in a domain-specific editor. */
readonly editor: MakeEditor<State, Editor>
/**
* 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<void>
}
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
export interface Interface<State extends Objectish, Editor> {
readonly get: () => State
/**
* Registers and applies a scoped transform. Closing the owning Scope removes
* the transform and rebuilds the materialized 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<Editor>) => Effect.Effect<void>, never, Scope.Scope>
/** Registers and applies a replayable transform in the current Scope. */
readonly update: (update: Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
/**
* 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.
*/
readonly mutate: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
}
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
export function create<State extends Objectish, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
let state = options.initial()
let transforms: { run: TransformCallback<DraftApi> }[] = []
let transforms: { update: Transform<Editor> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
const commit = Effect.fn("State.commit")(function* (next: State) {
const api = options.draft(next)
if (options.finalize) yield* options.finalize(api)
const commit = Effect.fn("State.commit")(function* (next: State, reason?: string) {
const api = options.editor(next as Draft<State>)
if (options.finalize) yield* options.finalize(api, reason)
state = next
})
const apply = (transform: TransformCallback<DraftApi>, 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 rebuild = Effect.fnUntraced(function* () {
const next = options.initial()
const api = options.draft(next)
for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.rebuild.update"))
const api = options.editor(next as Draft<State>)
for (const transform of transforms)
yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {}))
yield* commit(next)
})
const rebuild = () => semaphore.withPermit(materialize())
const result: Interface<State, DraftApi> = {
const result: Interface<State, Editor> = {
get: () => state,
transform: Effect.fn("State.transform")(function* (update) {
transform: Effect.fn("State.transform")(function* () {
const scope = yield* Scope.Scope
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const transform = { run: update }
let active = true
const dispose = Effect.uninterruptible(
const transform = { update: (_editor: Editor) => {} }
transforms = [...transforms, transform]
yield* Scope.addFinalizer(
scope,
semaphore.withPermit(
Effect.suspend(() => {
if (!active) return Effect.void
active = false
Effect.sync(() => {
transforms = transforms.filter((item) => item !== transform)
return Effect.gen(function* () {
const batch = yield* CurrentBatch
if (batch) {
batch.add(rebuild)
return
}
yield* materialize()
})
}),
}).pipe(Effect.andThen(rebuild())),
),
)
yield* semaphore.withPermit(
Effect.sync(() => {
transforms = [...transforms, transform]
}),
)
yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch
if (batch) batch.add(rebuild)
else yield* rebuild()
return { dispose }
return (update: Transform<Editor>) =>
Effect.uninterruptible(
semaphore.withPermit(
Effect.sync(() => {
transform.update = update
}).pipe(Effect.andThen(rebuild())),
),
)
}),
)
}),
rebuild,
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<State>)
yield* update(api)
if (options.finalize) yield* options.finalize(api, reason)
}, semaphore.withPermit),
}
return result
}
+9 -5
View File
@@ -1,6 +1,7 @@
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"
@@ -8,7 +9,7 @@ type Data = {
readonly entries: Map<string, Entry>
}
type Draft = {
type Editor = {
readonly set: (name: string, entry: Entry) => void
}
@@ -26,12 +27,14 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {}
enableMapSet()
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
initial: () => ({ entries: new Map() }),
draft: (draft) => ({
editor: (draft) => ({
set: (name, tool) => {
draft.entries.set(name, tool)
},
@@ -44,8 +47,9 @@ 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)
yield* state.transform((draft) => {
for (const [name, entry] of registrations) draft.set(name, entry)
const transform = yield* state.transform()
yield* transform((editor) => {
for (const [name, entry] of registrations) editor.set(name, entry)
})
}),
entries: () => state.get().entries,
+1 -1
View File
@@ -502,7 +502,7 @@ export type WithParts = {
}
const options = {
durable: {
sync: {
aggregate: "sessionID",
version: 1,
},
+24 -19
View File
@@ -6,7 +6,6 @@ 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)
@@ -24,7 +23,9 @@ describe("AgentV2", () => {
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("reviewer")
yield* agent.transform((editor) =>
const transform = yield* agent.transform()
yield* transform((editor) =>
editor.update(id, (info) => {
info.description = "Reviews code"
info.mode = "subagent"
@@ -40,17 +41,19 @@ describe("AgentV2", () => {
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("reviewer")
let description = "Old description"
let hidden = true
yield* agent.transform((editor) =>
const transform = yield* agent.transform()
yield* transform((editor) =>
editor.update(id, (info) => {
info.description = description
info.hidden = hidden
info.description = "Old description"
info.hidden = true
}),
)
yield* transform((editor) =>
editor.update(id, (info) => {
info.description = "New description"
}),
)
description = "New description"
hidden = false
yield* agent.rebuild()
expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false })
}),
@@ -61,7 +64,9 @@ describe("AgentV2", () => {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("scoped")
const scope = yield* Scope.make()
yield* agent.transform((editor) => editor.update(id, () => {})).pipe(Scope.provide(scope))
const transform = yield* agent.transform().pipe(Scope.provide(scope))
yield* transform((editor) => editor.update(id, () => {}))
expect(yield* agent.get(id)).toBeDefined()
yield* Scope.close(scope, Exit.void)
@@ -74,7 +79,7 @@ describe("AgentV2", () => {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("build")
yield* agent.transform((editor) =>
yield* agent.update((editor) =>
editor.update(id, (info) => {
info.mode = "primary"
info.hidden = true
@@ -90,10 +95,10 @@ describe("AgentV2", () => {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("custom")
yield* agent.transform((editor) => editor.update(id, () => {}))
yield* agent.update((editor) => editor.update(id, () => {}))
expect(yield* agent.get(id)).toEqual(AgentV2.Info.empty(id))
yield* agent.transform((editor) => editor.remove(id))
yield* agent.update((editor) => editor.remove(id))
expect(yield* agent.get(id)).toBeUndefined()
}),
)
@@ -101,11 +106,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(
host({
agent: agentHost(agent),
location: location({ directory: AbsolutePath.make("/project") }),
}),
yield* AgentPlugin.Plugin.effect.pipe(
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
),
)
const agents = yield* agent.all()
+148 -38
View File
@@ -1,17 +1,18 @@
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Stream } from "effect"
import { DateTime, 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"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { Project } from "@opencode-ai/core/project"
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 { required } from "./plugin/provider-helper"
const locationLayer = Layer.succeed(
Location.Service,
@@ -40,7 +41,7 @@ describe("CatalogV2", () => {
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
yield* (yield* catalog.transform())((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
expect((yield* Fiber.join(updated)).length).toBe(1)
}),
@@ -75,13 +76,14 @@ describe("CatalogV2", () => {
return Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
const transform = yield* catalog.transform()
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
active = second
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
}).pipe(Effect.provide(layer))
})
@@ -97,13 +99,13 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const providerID = ProviderV2.ID.make("test")
yield* integrations.transform((editor) =>
yield* integrations.update((editor) =>
editor.method.update({
integrationID: Integration.ID.make(providerID),
method: { type: "env", names: ["CATALOG_TEST_API_KEY"] },
}),
)
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {}))
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
}),
@@ -119,7 +121,9 @@ describe("CatalogV2", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
yield* catalog.transform((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(providerID, (provider) => {
provider.api = {
type: "aisdk",
@@ -130,7 +134,7 @@ describe("CatalogV2", () => {
}),
)
expect(required(yield* catalog.provider.get(providerID)).api).toEqual({
expect((yield* catalog.provider.get(providerID)).api).toEqual({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://override.example.com",
@@ -143,7 +147,9 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
yield* catalog.transform((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.api = {
type: "aisdk",
@@ -162,7 +168,7 @@ describe("CatalogV2", () => {
})
})
expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({
expect((yield* catalog.model.get(providerID, modelID)).api).toEqual({
id: modelID,
type: "aisdk",
package: "@ai-sdk/openai-compatible",
@@ -177,7 +183,9 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
yield* catalog.transform((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.api = {
type: "aisdk",
@@ -188,7 +196,7 @@ describe("CatalogV2", () => {
catalog.model.update(providerID, modelID, () => {})
})
expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({
expect((yield* catalog.model.get(providerID, modelID)).api).toEqual({
id: modelID,
type: "aisdk",
package: "@ai-sdk/openai-compatible",
@@ -197,12 +205,106 @@ describe("CatalogV2", () => {
}),
)
it.effect("runs catalog transform hooks after baseURL is normalized", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
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({
"catalog.transform": (evt) =>
Effect.sync(() => {
const item = evt.provider.get(providerID)
if (!item) return
seen.push(item.provider.api.type)
if (item?.provider.api.type === "aisdk") seen.push(item.provider.api.url)
seen.push(item?.provider.request.body.baseURL)
}),
}),
})
yield* transform((catalog) =>
catalog.provider.update(providerID, (provider) => {
provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
provider.request.body.baseURL = "https://provider.example.com"
}),
)
expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined])
}),
)
it.effect("runs catalog transform when a plugin is added", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("test")
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(providerID, (provider) => {
provider.name = "Before"
}),
)
yield* plugin.add({
id: PluginV2.ID.make("test-transform"),
effect: Effect.succeed({
"catalog.transform": (evt) =>
Effect.sync(() =>
evt.provider.update(providerID, (provider) => {
provider.name = "After"
}),
),
}),
})
yield* Effect.yieldNow
expect((yield* catalog.provider.get(providerID)).name).toBe("After")
}),
)
it.effect("ignores plugin additions from another location", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const plugin = yield* PluginV2.Service
let invoked = 0
yield* plugin.add({
id: PluginV2.ID.make("test-transform"),
effect: Effect.succeed({
"catalog.transform": () => Effect.sync(() => invoked++),
}),
})
yield* Effect.yieldNow
expect(invoked).toBe(1)
yield* events.publish(
PluginV2.Event.Added,
{ id: PluginV2.ID.make("test-transform") },
{
location: new Location.Info({
directory: AbsolutePath.make("other"),
project: { id: Project.ID.global, directory: AbsolutePath.make("other") },
}),
},
)
yield* Effect.yieldNow
expect(invoked).toBe(1)
}),
)
it.effect("resolves provider and model request merges", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
yield* catalog.transform((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.request.headers.provider = "provider"
provider.request.headers.shared = "provider"
@@ -219,7 +321,7 @@ describe("CatalogV2", () => {
})
})
const model = required(yield* catalog.model.get(providerID, modelID))
const model = yield* catalog.model.get(providerID, modelID)
expect(model.request.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
expect(model.request.body).toEqual({ provider: true, model: true, request: true })
expect(model.request.options).toEqual({ shared: "model", model: true })
@@ -230,17 +332,19 @@ describe("CatalogV2", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
yield* catalog.transform((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
model.time.released = 1000
model.time.released = DateTime.makeUnsafe(1000)
})
catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => {
model.time.released = 2000
model.time.released = DateTime.makeUnsafe(2000)
})
})
expect((yield* catalog.model.default())?.id).toMatch("new")
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toMatch("new")
}),
)
@@ -250,26 +354,26 @@ describe("CatalogV2", () => {
const providerID = ProviderV2.ID.make("test")
const old = ModelV2.ID.make("old")
const newest = ModelV2.ID.make("new")
const models = (catalog: Catalog.Draft) => {
const transform = yield* catalog.transform()
const models = (catalog: Catalog.Editor) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, old, (model) => {
model.time.released = 1000
model.time.released = DateTime.makeUnsafe(1000)
})
catalog.model.update(providerID, newest, (model) => {
model.time.released = 2000
model.time.released = DateTime.makeUnsafe(2000)
})
}
let configured = true
yield* catalog.transform((catalog) => {
yield* transform((catalog) => {
models(catalog)
if (configured) catalog.model.default.set(providerID, old)
catalog.model.default.set(providerID, old)
})
expect((yield* catalog.model.default())?.id).toBe(old)
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(old)
configured = false
yield* catalog.rebuild()
expect((yield* catalog.model.default())?.id).toBe(newest)
yield* transform(models)
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(newest)
}),
)
@@ -280,7 +384,9 @@ describe("CatalogV2", () => {
const enabledProvider = ProviderV2.ID.make("enabled")
const disabledModel = ModelV2.ID.make("configured")
const fallbackModel = ModelV2.ID.make("fallback")
yield* catalog.transform((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(disabledProvider, (provider) => {
provider.disabled = true
})
@@ -290,7 +396,7 @@ describe("CatalogV2", () => {
catalog.model.default.set(disabledProvider, disabledModel)
})
expect(yield* catalog.model.default()).toMatchObject({
expect(Option.getOrUndefined(yield* catalog.model.default())).toMatchObject({
providerID: enabledProvider,
id: fallbackModel,
})
@@ -301,23 +407,25 @@ describe("CatalogV2", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
yield* catalog.transform((catalog) => {
const transform = yield* catalog.transform()
yield* 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 = Date.now()
model.time.released = DateTime.makeUnsafe(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 = Date.now()
model.time.released = DateTime.makeUnsafe(Date.now())
})
})
expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
}),
)
@@ -326,15 +434,17 @@ 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* catalog.transform((catalog) => {
yield* transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("model"), () => {})
})
expect(yield* catalog.provider.all()).toEqual([])
expect(yield* catalog.model.all()).toEqual([])
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
expect(yield* catalog.provider.get(providerID).pipe(Effect.option)).toEqual(Option.none())
}),
)
})
+2 -1
View File
@@ -11,7 +11,8 @@ describe("CommandV2", () => {
it.effect("applies command transforms and preserves later overrides", () =>
Effect.gen(function* () {
const command = yield* CommandV2.Service
yield* command.transform((editor) => {
const transform = yield* command.transform()
yield* transform((editor) => {
editor.update("review", (command) => {
command.template = "First"
command.description = "Review code"
+13 -7
View File
@@ -10,7 +10,6 @@ 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)
@@ -20,7 +19,9 @@ describe("ConfigAgentPlugin.Plugin", () => {
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const build = AgentV2.ID.make("build")
yield* agents.transform((editor) =>
const defaults = yield* agents.transform()
yield* defaults((editor) =>
editor.update(build, (agent) => {
agent.mode = "primary"
agent.permissions.push({ action: "bash", resource: "*", effect: "allow" })
@@ -67,8 +68,9 @@ describe("ConfigAgentPlugin.Plugin", () => {
]),
})
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
yield* ConfigAgentPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(AgentV2.Service, agents),
)
const buildAgent = yield* agents.get(build)
@@ -148,8 +150,9 @@ describe("ConfigAgentPlugin.Plugin", () => {
]),
})
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
yield* ConfigAgentPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(AgentV2.Service, agents),
)
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
@@ -174,7 +177,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const build = AgentV2.ID.make("build")
yield* agents.transform((editor) => editor.update(build, () => {}))
const defaults = yield* agents.transform()
yield* defaults((editor) => editor.update(build, () => {}))
const config = Config.Service.of({
entries: () =>
@@ -186,8 +190,9 @@ describe("ConfigAgentPlugin.Plugin", () => {
]),
})
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
yield* ConfigAgentPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(AgentV2.Service, agents),
)
expect(yield* agents.get(build)).toBeUndefined()
@@ -246,8 +251,9 @@ Use native v2 fields.`,
]),
})
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
yield* ConfigAgentPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(AgentV2.Service, agents),
)
expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({
+2 -2
View File
@@ -11,7 +11,6 @@ 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)
@@ -42,7 +41,8 @@ Review files`,
})
const command = yield* CommandV2.Service
yield* ConfigCommandPlugin.Plugin.effect(host({ command })).pipe(
yield* ConfigCommandPlugin.Plugin.effect.pipe(
Effect.provideService(CommandV2.Service, command),
Effect.provideService(
Config.Service,
Config.Service.of({
+21 -16
View File
@@ -7,8 +7,7 @@ import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, required, withEnv } from "../plugin/provider-helper"
import { catalogHost, host, integrationHost } from "../plugin/host"
import { it, withEnv } from "../plugin/provider-helper"
function request(headers: Record<string, string>, variant?: string) {
return {
@@ -59,12 +58,14 @@ describe("ConfigProviderPlugin.Plugin", () => {
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
).pipe(Effect.provideService(Config.Service, config)),
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(Integration.Service, integrations),
),
})
const model = required(yield* catalog.model.get(providerID, modelID))
const model = yield* catalog.model.get(providerID, modelID)
expect(model.variants).toMatchObject([
{
id: "high",
@@ -118,12 +119,14 @@ describe("ConfigProviderPlugin.Plugin", () => {
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
).pipe(Effect.provideService(Config.Service, config)),
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(Integration.Service, integrations),
),
})
const model = required(yield* catalog.model.get(providerID, modelID))
const model = yield* catalog.model.get(providerID, modelID)
expect(model.variants[0]).toMatchObject({
id: "high",
body: {},
@@ -219,14 +222,16 @@ describe("ConfigProviderPlugin.Plugin", () => {
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
).pipe(Effect.provideService(Config.Service, config)),
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(Integration.Service, integrations),
),
})
const provider = required(yield* catalog.provider.get(providerID))
const model = required(yield* catalog.model.get(providerID, modelID))
expect((yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
const provider = yield* catalog.provider.get(providerID)
const model = yield* catalog.model.get(providerID, modelID)
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
expect(provider.name).toBe("Renamed")
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
type: "env",
+17 -25
View File
@@ -9,7 +9,6 @@ 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)
@@ -19,33 +18,16 @@ describe("ConfigSkillPlugin.Plugin", () => {
Effect.gen(function* () {
const directory = AbsolutePath.make("/repo/packages/app")
const sources: SkillV2.Source[] = []
const transform = Effect.fnUntraced(function* (update: (draft: SkillV2.Draft) => void | Effect.Effect<void>) {
const result = update({
source: (source) => {
sources.push(source)
},
list: () => sources,
const transform = Effect.fnUntraced(function* () {
return Effect.fnUntraced(function* (update: (editor: SkillV2.Editor) => void) {
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(
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(
yield* ConfigSkillPlugin.Plugin.effect.pipe(
Effect.provideService(
Config.Service,
Config.Service.of({
@@ -61,6 +43,16 @@ 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([
+88 -36
View File
@@ -30,7 +30,7 @@ const Message = EventV2.define({
const SyncMessage = EventV2.define({
type: "test.sync",
durable: {
sync: {
version: 1,
aggregate: "id",
},
@@ -42,7 +42,7 @@ const SyncMessage = EventV2.define({
const SyncSent = EventV2.define({
type: "test.sent",
durable: {
sync: {
version: 1,
aggregate: "messageID",
},
@@ -61,7 +61,7 @@ const GlobalMessage = EventV2.define({
const VersionedMessage = EventV2.define({
type: "test.versioned",
durable: {
sync: {
version: 2,
aggregate: "id",
},
@@ -73,7 +73,7 @@ const VersionedMessage = EventV2.define({
const SyncTimestamp = EventV2.define({
type: "test.timestamp",
durable: {
sync: {
version: 1,
aggregate: "id",
},
@@ -132,7 +132,7 @@ describe("EventV2", () => {
const event = yield* events.publish(VersionedMessage, { id: "one", text: "hello" })
expect(event.type).toBe("test.versioned")
expect(event.durable?.version).toBe(2)
expect(event.version).toBe(2)
}),
)
@@ -146,12 +146,12 @@ describe("EventV2", () => {
Effect.sync(() => {
const latest = EventV2.define({
type: "test.out-of-order",
durable: { version: 2, aggregate: "id" },
sync: { version: 2, aggregate: "id" },
schema: { id: Schema.String },
})
EventV2.define({
type: "test.out-of-order",
durable: { version: 1, aggregate: "id" },
sync: { version: 1, aggregate: "id" },
schema: { id: Schema.String },
})
@@ -190,7 +190,7 @@ describe("EventV2", () => {
}),
)
it.effect("commits local operational state inside a new durable event transaction", () =>
it.effect("commits local operational state inside a new synchronized event transaction", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<string>()
@@ -207,7 +207,7 @@ describe("EventV2", () => {
}),
)
it.effect("rolls back the durable event and projector when the local commit fails", () =>
it.effect("rolls back the synchronized event and projector when the local commit fails", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
@@ -236,7 +236,7 @@ describe("EventV2", () => {
const events = yield* EventV2.Service
const exit = yield* events.publish(Message, { text: "hello" }, { commit: () => Effect.void }).pipe(Effect.exit)
expect(String(exit)).toContain("Local commit hooks require a durable event")
expect(String(exit)).toContain("Local commit hooks require a synchronized event")
}),
)
@@ -290,6 +290,7 @@ describe("EventV2", () => {
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<string>()
yield* events.sync(() => Effect.die("sync defect"))
yield* events.listen(() => {
throw new Error("listener defect")
})
@@ -302,7 +303,7 @@ describe("EventV2", () => {
const event = yield* events.publish(SyncMessage, { id: "one", text: "hello" })
expect(received).toEqual([SyncMessage.type])
expect(event.durable?.seq).toBeNumber()
expect(event.seq).toBeNumber()
}),
)
@@ -335,7 +336,49 @@ describe("EventV2", () => {
}),
)
it.effect("inserts durable event rows on publish", () =>
it.effect("does not synchronize live-only events", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const synchronized = new Array<string>()
const unsubscribe = yield* events.sync((event) =>
Effect.sync(() => {
synchronized.push(event.type)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
yield* events.publish(Message, { text: "live only" })
yield* events.publish(SyncMessage, { id: "one", text: "durable" })
expect(synchronized).toEqual([SyncMessage.type])
}),
)
it.effect("synchronizes only after the durable event commits", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const synchronized = new Array<boolean>()
yield* events.sync((event) =>
db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.id, event.id))
.get()
.pipe(
Effect.orDie,
Effect.map((row) => synchronized.push(row !== undefined)),
Effect.asVoid,
),
)
yield* events.publish(SyncMessage, { id: EventV2.ID.create(), text: "durable" })
expect(synchronized).toEqual([true])
}),
)
it.effect("inserts sync event rows on publish", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
@@ -355,7 +398,7 @@ describe("EventV2", () => {
}),
)
it.effect("increments durable event seq per aggregate", () =>
it.effect("increments sync event seq per aggregate", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
@@ -374,22 +417,22 @@ describe("EventV2", () => {
}),
)
it.effect("replays durable aggregate events after a sequence and tails new events", () =>
it.effect("replays durable aggregate events after a cursor and tails new events", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
const fiber = yield* events
.durable({ aggregateID, after: 0 })
.aggregateEvents({ aggregateID, after: EventV2.Cursor.make(0) })
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* events.publish(SyncMessage, { id: aggregateID, text: "two" })
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[1, { id: aggregateID, text: "one" }],
[2, { id: aggregateID, text: "two" }],
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([
[EventV2.Cursor.make(1), { id: aggregateID, text: "one" }],
[EventV2.Cursor.make(2), { id: aggregateID, text: "two" }],
])
}),
)
@@ -399,18 +442,20 @@ describe("EventV2", () => {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
const fiber = yield* events
.aggregateEvents({ aggregateID })
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
expect(
Array.from(yield* Fiber.join(fiber)).map((event) => [
event.durable?.seq,
(event.data as { text: string }).text,
event.cursor,
(event.event.data as { text: string }).text,
]),
).toEqual([
[0, "zero"],
[1, "one"],
[EventV2.Cursor.make(0), "zero"],
[EventV2.Cursor.make(1), "one"],
])
}),
)
@@ -431,15 +476,17 @@ describe("EventV2", () => {
yield* Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const fiber = yield* events
.aggregateEvents({ aggregateID })
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Deferred.await(readStarted)
pause = false
yield* events.publish(SyncMessage, { id: aggregateID, text: "during handoff" })
yield* Deferred.succeed(continueRead, undefined)
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, { id: aggregateID, text: "during handoff" }],
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([
[EventV2.Cursor.make(0), { id: aggregateID, text: "during handoff" }],
])
}).pipe(Effect.provide(Layer.mergeAll(database, eventLayer)))
}),
@@ -451,7 +498,7 @@ describe("EventV2", () => {
const aggregateID = EventV2.ID.create()
const count = 64
const fiber = yield* events
.durable({ aggregateID })
.aggregateEvents({ aggregateID })
.pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
@@ -459,8 +506,11 @@ describe("EventV2", () => {
yield* events.publish(SyncMessage, { id: aggregateID, text: String(index) })
}
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
Array.from({ length: count }, (_, index) => [index, { id: aggregateID, text: String(index) }]),
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual(
Array.from({ length: count }, (_, index) => [
EventV2.Cursor.make(index),
{ id: aggregateID, text: String(index) },
]),
)
}),
)
@@ -469,13 +519,15 @@ describe("EventV2", () => {
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const fiber = yield* events
.aggregateEvents({ aggregateID })
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* events.publish(Message, { text: "live only" })
yield* events.publish(SyncMessage, { id: aggregateID, text: "durable" })
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([SyncMessage.type])
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.event.type)).toEqual([SyncMessage.type])
}),
)
@@ -498,7 +550,7 @@ describe("EventV2", () => {
}),
)
it.effect("replays durable events through projectors", () =>
it.effect("replays sync events through projectors", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<EventV2.Payload>()
@@ -654,7 +706,7 @@ describe("EventV2", () => {
})
.pipe(Effect.exit)
expect(String(exit)).toContain("Unknown durable event type")
expect(String(exit)).toContain("Unknown sync event type")
}),
)
@@ -791,7 +843,7 @@ describe("EventV2", () => {
const replayed = {
id: published.id,
type: EventV2.versionedType(SyncMessage.type, 1),
seq: published.durable!.seq,
seq: published.seq!,
aggregateID,
data: published.data,
}
@@ -936,7 +988,7 @@ describe("EventV2", () => {
yield* events.replay(replayed, { publish: true })
yield* events.replay(replayed, { publish: true })
expect(received).toMatchObject([{ id: replayed.id, durable: { seq: 0, version: 1 }, data: replayed.data }])
expect(received).toMatchObject([{ id: replayed.id, seq: 0, data: replayed.data }])
}),
)
@@ -1058,7 +1110,7 @@ describe("EventV2", () => {
}),
)
it.effect("remove clears durable event sequence", () =>
it.effect("remove clears sync event sequence", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<EventV2.Payload>()
+26 -20
View File
@@ -2,6 +2,7 @@ import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
@@ -11,14 +12,16 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
function provide(directory: string, filesystem = FSUtil.defaultLayer) {
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
function provide(directory: string, filesystem?: LayerNode.Replacement<FSUtil.Service>) {
const activeLocation = LayerNode.make(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
[],
)
return Effect.provide(
LayerNode.buildLayer(LayerNode.group([LocationMutation.node(activeLocation), FileMutation.node]), {
replacements: filesystem ? [filesystem] : [],
}),
)
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
return Effect.provide(Layer.mergeAll(resolution, mutation))
}
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
@@ -347,17 +350,20 @@ describe("FileMutation", () => {
})
function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>) {
return Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const filesystem = yield* FSUtil.Service
return FSUtil.Service.of({
...filesystem,
writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target),
writeFile: (target, content, options) => run(filesystem.writeFile(target, content, options), target),
writeFileString: (target, content, options) =>
run(filesystem.writeFileString(target, content, options), target),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
return LayerNode.replace(
FSUtil.node,
Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const filesystem = yield* FSUtil.Service
return FSUtil.Service.of({
...filesystem,
writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target),
writeFile: (target, content, options) => run(filesystem.writeFile(target, content, options), target),
writeFileString: (target, content, options) =>
run(filesystem.writeFileString(target, content, options), target),
})
}),
).pipe(Layer.provide(LayerNode.buildLayer(FSUtil.node))),
)
}
+11 -11
View File
@@ -51,7 +51,7 @@ describe("Integration", () => {
const openai = Integration.ID.make("openai")
yield* integrations
.transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI")))
.update((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
.transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI")))
.update((editor) => editor.update(id, (integration) => (integration.name = "OpenAI")))
.pipe(Scope.provide(first))
yield* integrations
.transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI Override")))
.update((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
.transform((editor) =>
.update((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
@@ -108,7 +108,7 @@ describe("Integration", () => {
)
.pipe(Scope.provide(first))
yield* integrations
.transform((editor) => {
.update((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.transform((editor) =>
yield* integrations.update((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.transform((editor) =>
yield* integrations.update((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.transform((editor) =>
yield* integrations.update((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.transform((editor) =>
yield* integrations.update((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.transform((editor) =>
yield* integrations.update((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.transform((editor) =>
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: {
+5 -38
View File
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import { Effect, Exit, Layer } from "effect"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
@@ -31,13 +31,6 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
describe("FileSystem", () => {
const expectFail = (exit: Exit.Exit<unknown, unknown>) => {
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isSuccess(exit)) return
expect(Cause.hasFails(exit.cause)).toBe(true)
expect(Cause.hasDies(exit.cause)).toBe(false)
}
it.live("reads text and binary files", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -67,39 +60,13 @@ describe("FileSystem", () => {
),
)
it.live("fails for missing paths", () =>
it.live("rejects lexical escapes", () =>
withTmp((directory) =>
Effect.gen(function* () {
const exit = yield* (yield* FileSystem.Service)
.read({ path: RelativePath.make("missing.txt") })
const result = yield* (yield* FileSystem.Service)
.read({ path: RelativePath.make("../outside.txt") })
.pipe(Effect.exit)
expectFail(exit)
}).pipe(provide(directory)),
),
)
it.live("fails for wrong path kinds", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
const service = yield* FileSystem.Service
expectFail(yield* service.read({ path: RelativePath.make("src") }).pipe(Effect.exit))
expectFail(yield* service.list({ path: RelativePath.make("README.md") }).pipe(Effect.exit))
}).pipe(provide(directory)),
),
)
it.live("fails for lexical and symlink escapes", () =>
withTmp((directory) =>
Effect.gen(function* () {
const outside = path.join(directory, "..", "outside.txt")
yield* Effect.promise(() => fs.writeFile(outside, "outside"))
yield* Effect.promise(() => fs.symlink(outside, path.join(directory, "linked.txt")))
const service = yield* FileSystem.Service
expectFail(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit))
expectFail(yield* service.read({ path: RelativePath.make("linked.txt") }).pipe(Effect.exit))
yield* Effect.promise(() => fs.rm(outside))
expect(Exit.isFailure(result)).toBe(true)
}).pipe(provide(directory)),
),
)
+3 -53
View File
@@ -1,10 +1,8 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect"
import { Effect, Equal, Hash, Layer, Schema } 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"
@@ -88,7 +86,8 @@ describe("LocationServiceMap", () => {
yield* PluginBoot.Service.use((boot) => boot.wait())
yield* Reference.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
const transform = yield* catalog.transform()
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
return {
providers: yield* catalog.provider.all(),
tools: yield* toolDefinitions(yield* ToolRegistry.Service),
@@ -136,53 +135,4 @@ 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<void>()
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) }))),
),
),
),
)
})
+1 -1
View File
@@ -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(entry.entrypoint).toBeDefined()
expect(Option.isSome(entry.entrypoint)).toBe(true)
})
})
+7 -5
View File
@@ -74,7 +74,8 @@ function setup(rules: PermissionV2.Ruleset = []) {
function setRules(rules: PermissionV2.Ruleset) {
return Effect.gen(function* () {
const agents = yield* AgentV2.Service
yield* agents.transform((editor) =>
const update = yield* agents.transform()
yield* update((editor) =>
editor.update(AgentV2.ID.make("test"), (agent) => {
agent.permissions = [...rules]
}),
@@ -129,7 +130,7 @@ describe("PermissionV2", () => {
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const agents = yield* AgentV2.Service
yield* agents.transform((editor) =>
yield* agents.update((editor) =>
editor.update(AgentV2.ID.make("reviewer"), (agent) => {
agent.permissions.push({ action: "read", resource: "*", effect: "deny" })
}),
@@ -138,7 +139,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.transform((editor) =>
yield* agents.update((editor) =>
editor.update(AgentV2.ID.make("reviewer"), (agent) => {
agent.permissions = []
}),
@@ -186,7 +187,8 @@ describe("PermissionV2", () => {
.run()
.pipe(Effect.orDie)
const agents = yield* AgentV2.Service
yield* agents.transform((editor) =>
const update = yield* agents.transform()
yield* update((editor) =>
editor.update(AgentV2.ID.make("build"), (agent) => {
agent.permissions = [{ action: "todowrite", resource: "*", effect: "allow" }]
}),
@@ -212,7 +214,7 @@ describe("PermissionV2", () => {
.run()
.pipe(Effect.orDie)
const agents = yield* AgentV2.Service
yield* agents.transform((editor) => {
yield* agents.update((editor) => {
editor.remove(AgentV2.ID.make("test"))
editor.remove(AgentV2.ID.make("build"))
})
+7 -10
View File
@@ -18,7 +18,7 @@ const plugins = PluginV2.layer.pipe(Layer.provide(events))
function state() {
return State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({
editor: (draft) => ({
add: (value: string) => draft.values.push(value),
}),
})
@@ -34,9 +34,8 @@ describe("PluginV2", () => {
yield* plugin.add({
id: PluginV2.ID.make("scoped"),
effect: Effect.gen(function* () {
yield* values.transform((editor) => {
editor.add("scoped")
})
const transform = yield* values.transform()
yield* transform((editor) => editor.add("scoped"))
}),
})
expect(values.get().values).toEqual(["scoped"])
@@ -59,9 +58,8 @@ describe("PluginV2", () => {
.add({
id,
effect: Effect.gen(function* () {
yield* values.transform((editor) => {
editor.add("first")
})
const transform = yield* values.transform()
yield* transform((editor) => editor.add("first"))
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(releaseFirst)
}),
@@ -73,9 +71,8 @@ describe("PluginV2", () => {
.add({
id,
effect: Effect.gen(function* () {
yield* values.transform((editor) => {
editor.add("second")
})
const transform = yield* values.transform()
yield* transform((editor) => editor.add("second"))
}),
})
.pipe(Effect.forkChild({ startImmediately: true }))
+6 -6
View File
@@ -6,7 +6,6 @@ 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")
@@ -22,11 +21,12 @@ describe("CommandPlugin.Plugin", () => {
it.effect("registers built-in init and review commands", () =>
Effect.gen(function* () {
const command = yield* CommandV2.Service
yield* CommandPlugin.Plugin.effect(
host({
command,
location: location({ directory }, { projectDirectory: project }),
}),
yield* CommandPlugin.Plugin.effect.pipe(
Effect.provideService(CommandV2.Service, command),
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory }, { projectDirectory: project })),
),
)
expect(yield* command.get("init")).toMatchObject({
-319
View File
@@ -1,319 +0,0 @@
import type { AISDKHooks, 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 { PluginV2 } from "@opencode-ai/core/plugin"
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> = {}): 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 aisdkHost(plugin: PluginV2.Interface): PluginHost["aisdk"] {
return {
hook: (name, callback) => {
if (name === "sdk") {
const run = callback as AISDKHooks["sdk"]
return plugin.hook("aisdk.sdk", (event) => {
const output = { ...event }
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 AISDKHooks["language"]
return plugin.hook("aisdk.language", (event) => {
const output = { ...event }
const result = run(output)
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() => Effect.sync(() => (event.language = output.language))),
)
})
},
}
}
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 },
}
}
+2 -10
View File
@@ -1,6 +1,6 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { Effect, Layer } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential"
@@ -15,7 +15,6 @@ 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(
@@ -57,15 +56,8 @@ 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"),
@@ -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 { addPlugin, it, model } from "./provider-helper"
import { 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* addPlugin(plugin, AlibabaPlugin)
yield* plugin.add(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* addPlugin(plugin, AlibabaPlugin)
yield* plugin.add(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* addPlugin(plugin, AlibabaPlugin)
yield* plugin.add(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* addPlugin(plugin, AlibabaPlugin)
yield* plugin.add(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)
@@ -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 { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper"
import { 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,8 +30,9 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* catalog.transform((catalog) => {
yield* plugin.add(AmazonBedrockPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const bedrock = provider("amazon-bedrock", {
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
request: {
@@ -44,7 +45,7 @@ describe("AmazonBedrockPlugin", () => {
item.request = bedrock.request
})
})
const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock))
const result = yield* catalog.provider.get(ProviderV2.ID.amazonBedrock)
expect(result.api).toEqual({
type: "aisdk",
package: "@ai-sdk/amazon-bedrock",
@@ -58,7 +59,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* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -83,7 +84,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* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -117,7 +118,7 @@ describe("AmazonBedrockPlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -137,7 +138,7 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -156,7 +157,7 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -175,7 +176,7 @@ describe("AmazonBedrockPlugin", () => {
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -195,7 +196,7 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const headers: Array<string | null> = []
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -224,7 +225,7 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const headers: Array<string | null> = []
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -252,7 +253,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* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -281,7 +282,7 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.add(AmazonBedrockPlugin)
yield* plugin.trigger(
"aisdk.language",
{
@@ -311,7 +312,7 @@ describe("AmazonBedrockPlugin", () => {
it.effect("ignores other Bedrock provider subpaths", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -340,7 +341,7 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const headers: Array<string | null> = []
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -371,7 +372,7 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.add(AmazonBedrockPlugin)
yield* plugin.trigger(
"aisdk.language",
{
@@ -432,7 +433,7 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.add(AmazonBedrockPlugin)
yield* plugin.trigger(
"aisdk.language",
{
@@ -517,7 +518,7 @@ describe("AmazonBedrockPlugin", () => {
expected: "au.anthropic.claude-sonnet-4-5",
},
]
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.add(AmazonBedrockPlugin)
for (const item of cases) {
yield* plugin.trigger(
"aisdk.language",
@@ -537,7 +538,7 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AmazonBedrockPlugin)
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{
@@ -4,15 +4,16 @@ 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 { addPlugin, it, model, provider, required } from "./provider-helper"
import { 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* addPlugin(plugin, AnthropicPlugin)
yield* catalog.transform((catalog) => {
yield* plugin.add(AnthropicPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("anthropic", {
api: { type: "aisdk", package: "@ai-sdk/anthropic" },
request: { headers: { Existing: "1" }, body: {} },
@@ -22,10 +23,10 @@ describe("AnthropicPlugin", () => {
draft.request = item.request
})
})
expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe(
expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe(
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
)
expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers.Existing).toBe("1")
expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers.Existing).toBe("1")
}),
)
@@ -33,11 +34,10 @@ describe("AnthropicPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AnthropicPlugin)
yield* catalog.transform((catalog) => catalog.provider.update(provider("openai").id, () => {}))
expect(
required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"],
).toBeUndefined()
yield* plugin.add(AnthropicPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(provider("openai").id, () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"]).toBeUndefined()
}),
)
@@ -45,7 +45,7 @@ describe("AnthropicPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const providers: string[] = []
yield* addPlugin(plugin, AnthropicPlugin)
yield* plugin.add(AnthropicPlugin)
yield* plugin.add({
id: PluginV2.ID.make("anthropic-sdk-inspector"),
effect: Effect.succeed({
@@ -72,7 +72,7 @@ describe("AnthropicPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const providers: string[] = []
yield* addPlugin(plugin, AnthropicPlugin)
yield* plugin.add(AnthropicPlugin)
yield* plugin.add({
id: PluginV2.ID.make("anthropic-sdk-inspector"),
effect: Effect.succeed({
@@ -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 { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
describe("AzureCognitiveServicesPlugin", () => {
it.effect("maps the resource env var to the Azure SDK baseURL", () =>
@@ -12,13 +12,14 @@ describe("AzureCognitiveServicesPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* catalog.transform((catalog) => {
yield* plugin.add(AzureCognitiveServicesPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
})
})
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
const result = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))
expect(result.api).toEqual({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
@@ -35,8 +36,9 @@ describe("AzureCognitiveServicesPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* catalog.transform((catalog) => {
yield* plugin.add(AzureCognitiveServicesPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const azure = provider("azure-cognitive-services", {
api: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
})
@@ -48,8 +50,8 @@ describe("AzureCognitiveServicesPlugin", () => {
item.api = openai.api
})
})
const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai))
const azure = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))
const openai = yield* catalog.provider.get(ProviderV2.ID.openai)
expect(azure.request.body.baseURL).toBeUndefined()
expect(azure.api).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible" })
expect(openai.request.body.baseURL).toBeUndefined()
@@ -62,7 +64,7 @@ describe("AzureCognitiveServicesPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* plugin.add(AzureCognitiveServicesPlugin)
yield* plugin.trigger(
"aisdk.language",
{
@@ -80,7 +82,7 @@ describe("AzureCognitiveServicesPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* plugin.add(AzureCognitiveServicesPlugin)
yield* plugin.trigger(
"aisdk.language",
{ model: model("azure-cognitive-services", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
@@ -101,7 +103,7 @@ describe("AzureCognitiveServicesPlugin", () => {
const plugin = yield* PluginV2.Service
const calls: string[] = []
const sdk = fakeSelectorSdk(calls)
yield* addPlugin(plugin, AzureCognitiveServicesPlugin)
yield* plugin.add(AzureCognitiveServicesPlugin)
yield* plugin.trigger(
"aisdk.language",
{
@@ -1,10 +1,35 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Database } from "@opencode-ai/core/database/database"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper"
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"
const database = Database.layerFromPath(":memory:").pipe(Layer.fresh)
const preferences = Credential.layer.pipe(Layer.provide(database))
const accounts = Layer.merge(
Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)),
preferences,
)
const itWithAccount = testEffect(
Catalog.locationLayer.pipe(
Layer.provideMerge(accounts),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
Layer.provideMerge(npmLayer),
),
)
describe("AzurePlugin", () => {
it.effect("resolves resourceName from env", () =>
@@ -12,13 +37,14 @@ describe("AzurePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
yield* plugin.add(AzurePlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.api = { type: "aisdk", package: "@ai-sdk/azure" }
})
})
yield* addPlugin(plugin, AzurePlugin)
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
}),
),
)
@@ -28,7 +54,9 @@ describe("AzurePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
yield* plugin.add(AzurePlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const azure = provider("azure", {
api: { type: "aisdk", package: "@ai-sdk/azure" },
request: { headers: {}, body: { resourceName: "from-config" } },
@@ -39,19 +67,50 @@ describe("AzurePlugin", () => {
})
catalog.provider.update(ProviderV2.ID.openai, () => {})
})
yield* addPlugin(plugin, AzurePlugin)
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config")
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined()
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config")
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined()
}),
),
)
itWithAccount.effect("prefers account resourceName over env", () =>
withEnv(
{
AZURE_RESOURCE_NAME: "from-env",
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* credentials.create({
integrationID: Integration.ID.make("azure"),
value: new Credential.Key({
type: "key",
key: "key",
metadata: { resourceName: "from-account" },
}),
})
yield* plugin.add(AzurePlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.api = { type: "aisdk", package: "@ai-sdk/azure" }
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-account")
}),
),
)
it.effect("falls back to env when configured resourceName is blank", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
yield* plugin.add(AzurePlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const azure = provider("azure", {
api: { type: "aisdk", package: "@ai-sdk/azure" },
request: { headers: {}, body: { resourceName: "" } },
@@ -61,8 +120,7 @@ describe("AzurePlugin", () => {
item.request = azure.request
})
})
yield* addPlugin(plugin, AzurePlugin)
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
}),
),
)
@@ -72,7 +130,9 @@ describe("AzurePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
yield* plugin.add(AzurePlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const azure = provider("azure", {
api: { type: "aisdk", package: "@ai-sdk/azure" },
request: { headers: {}, body: { resourceName: " " } },
@@ -82,8 +142,7 @@ describe("AzurePlugin", () => {
item.request = azure.request
})
})
yield* addPlugin(plugin, AzurePlugin)
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
}),
),
)
@@ -92,7 +151,7 @@ describe("AzurePlugin", () => {
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AzurePlugin)
yield* plugin.add(AzurePlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -111,7 +170,7 @@ describe("AzurePlugin", () => {
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, AzurePlugin)
yield* plugin.add(AzurePlugin)
const exit = yield* plugin
.trigger(
"aisdk.sdk",
@@ -128,7 +187,7 @@ describe("AzurePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AzurePlugin)
yield* plugin.add(AzurePlugin)
yield* plugin.trigger(
"aisdk.language",
{ model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } },
@@ -142,7 +201,7 @@ describe("AzurePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AzurePlugin)
yield* plugin.add(AzurePlugin)
yield* plugin.trigger(
"aisdk.language",
{ model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } },
@@ -156,7 +215,7 @@ describe("AzurePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AzurePlugin)
yield* plugin.add(AzurePlugin)
yield* plugin.trigger(
"aisdk.language",
{
@@ -176,7 +235,7 @@ describe("AzurePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, AzurePlugin)
yield* plugin.add(AzurePlugin)
yield* plugin.trigger(
"aisdk.language",
{ model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
@@ -200,7 +259,7 @@ describe("AzurePlugin", () => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" }
}
yield* addPlugin(plugin, AzurePlugin)
yield* plugin.add(AzurePlugin)
yield* plugin.trigger(
"aisdk.language",
{
@@ -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 { addPlugin, it, model, required } from "./provider-helper"
import { it, model } from "./provider-helper"
const cerebrasOptions: Record<string, unknown>[] = []
@@ -23,14 +23,15 @@ describe("CerebrasPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, CerebrasPlugin)
yield* catalog.transform((catalog) => {
yield* plugin.add(CerebrasPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
item.api = { type: "aisdk", package: "@ai-sdk/cerebras" }
item.request.headers.Existing = "1"
})
})
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).request.headers).toEqual({
expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).request.headers).toEqual({
Existing: "1",
"X-Cerebras-3rd-Party-Integration": "opencode",
})
@@ -41,9 +42,10 @@ describe("CerebrasPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* addPlugin(plugin, CerebrasPlugin)
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("groq"))).request.headers).toEqual({})
yield* plugin.add(CerebrasPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).request.headers).toEqual({})
}),
)
@@ -51,7 +53,7 @@ describe("CerebrasPlugin", () => {
Effect.gen(function* () {
cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CerebrasPlugin)
yield* plugin.add(CerebrasPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -70,7 +72,7 @@ describe("CerebrasPlugin", () => {
Effect.gen(function* () {
cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CerebrasPlugin)
yield* plugin.add(CerebrasPlugin)
yield* plugin.trigger(
"aisdk.sdk",
{
@@ -88,7 +90,7 @@ describe("CerebrasPlugin", () => {
Effect.gen(function* () {
cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CerebrasPlugin)
yield* plugin.add(CerebrasPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -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 { addPlugin, it, model, withEnv } from "./provider-helper"
import { it, model, withEnv } from "./provider-helper"
const aiGatewayCalls: Record<string, unknown>[] = []
const unifiedCalls: string[] = []
@@ -78,7 +78,7 @@ describe("CloudflareAIGatewayPlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* plugin.add(CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -98,7 +98,7 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* plugin.add(CloudflareAIGatewayPlugin)
yield* plugin.trigger(
"aisdk.sdk",
@@ -142,7 +142,7 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* plugin.add(CloudflareAIGatewayPlugin)
yield* plugin.trigger(
"aisdk.sdk",
@@ -171,7 +171,7 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* plugin.add(CloudflareAIGatewayPlugin)
yield* plugin.trigger(
"aisdk.sdk",
@@ -208,7 +208,7 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* plugin.add(CloudflareAIGatewayPlugin)
yield* plugin.trigger(
"aisdk.sdk",
@@ -239,7 +239,7 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* plugin.add(CloudflareAIGatewayPlugin)
yield* plugin.trigger(
"aisdk.sdk",
@@ -261,7 +261,7 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* plugin.add(CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
@@ -284,7 +284,7 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* plugin.add(CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
@@ -313,7 +313,7 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* plugin.add(CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
@@ -336,7 +336,7 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* plugin.add(CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
@@ -364,7 +364,7 @@ describe("CloudflareAIGatewayPlugin", () => {
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareAIGatewayPlugin)
yield* plugin.add(CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
@@ -1,11 +1,36 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Database } from "@opencode-ai/core/database/database"
import { Catalog } from "@opencode-ai/core/catalog"
import { Location } from "@opencode-ai/core/location"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper"
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"
const database = Database.layerFromPath(":memory:").pipe(Layer.fresh)
const preferences = Credential.layer.pipe(Layer.provide(database))
const accounts = Layer.merge(
Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)),
preferences,
)
const itWithAccount = testEffect(
Catalog.locationLayer.pipe(
Layer.provideMerge(accounts),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
Layer.provideMerge(npmLayer),
),
)
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
@@ -32,13 +57,14 @@ describe("CloudflareWorkersAIPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
yield* plugin.add(CloudflareWorkersAIPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.api = { type: "aisdk", package: "test-provider" }
}),
)
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")))
const provider = yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))
const sdk = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -63,13 +89,14 @@ describe("CloudflareWorkersAIPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
yield* plugin.add(CloudflareWorkersAIPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
}),
)
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
type: "aisdk",
package: "test-provider",
url: "https://proxy.example/v1",
@@ -82,7 +109,7 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
yield* plugin.add(CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -99,19 +126,56 @@ describe("CloudflareWorkersAIPlugin", () => {
),
)
itWithAccount.effect("falls back to account metadata when account env is absent", () =>
withEnv(
{
CLOUDFLARE_ACCOUNT_ID: undefined,
CLOUDFLARE_API_KEY: undefined,
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* credentials.create({
integrationID: Integration.ID.make("cloudflare-workers-ai"),
value: new Credential.Key({
type: "key",
key: "account-key",
metadata: { accountId: "account-acct" },
}),
})
yield* plugin.add(CloudflareWorkersAIPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.api = { type: "aisdk", package: "test-provider" }
}),
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).request.body).toMatchObject(
{
apiKey: "account-key",
accountId: "account-acct",
},
)
}),
),
)
it.effect("uses env account ID over configured account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
yield* plugin.add(CloudflareWorkersAIPlugin)
const transform = yield* catalog.transform()
yield* 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"
}),
)
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
type: "aisdk",
package: "test-provider",
url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1",
@@ -124,7 +188,7 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
yield* plugin.add(CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -153,7 +217,7 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
yield* plugin.add(CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -183,7 +247,7 @@ describe("CloudflareWorkersAIPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
yield* plugin.add(CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{
@@ -202,7 +266,7 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* addPlugin(plugin, CloudflareWorkersAIPlugin)
yield* plugin.add(CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@@ -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 { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper"
import { fakeSelectorSdk, it, model } from "./provider-helper"
const cohereOptions: Record<string, any>[] = []
@@ -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* addPlugin(plugin, CoherePlugin)
yield* plugin.add(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* addPlugin(plugin, CoherePlugin)
yield* plugin.add(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* addPlugin(plugin, CoherePlugin)
yield* plugin.add(CoherePlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{ model: model("cohere", "alias", { api: { id: ModelV2.ID.make("command-r-plus") } }), sdk, options: {} },

Some files were not shown because too many files have changed in this diff Show More