mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 19:09:49 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e69f3af711 |
@@ -1,7 +1,8 @@
|
|||||||
export * as Catalog from "./catalog"
|
export * as Catalog from "./catalog"
|
||||||
|
|
||||||
import { makeLocationNode } from "./effect/app-node"
|
import { makeLocationNode } from "./effect/app-node"
|
||||||
import { Array, Context, Effect, Layer, Option, Order, pipe } from "effect"
|
import { Array, Context, Effect, Exit, Layer, Option, Order, pipe, Schema, Stream, SubscriptionRef } from "effect"
|
||||||
|
import type { Scope } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/schema/catalog"
|
import { Catalog } from "@opencode-ai/schema/catalog"
|
||||||
import { ModelV2 } from "./model"
|
import { ModelV2 } from "./model"
|
||||||
import { ProviderV2 } from "./provider"
|
import { ProviderV2 } from "./provider"
|
||||||
@@ -18,11 +19,31 @@ export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
|
|||||||
|
|
||||||
export const Event = Catalog.Event
|
export const Event = Catalog.Event
|
||||||
|
|
||||||
|
export class IncompleteError extends Schema.TaggedErrorClass<IncompleteError>()("Catalog.IncompleteError", {}) {
|
||||||
|
override get message() {
|
||||||
|
return "Initial catalog discovery did not complete successfully"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Data = {
|
type Data = {
|
||||||
providers: Map<ProviderV2.ID, ProviderRecord>
|
providers: Map<ProviderV2.ID, ProviderRecord>
|
||||||
defaultModel?: DefaultModel
|
defaultModel?: DefaultModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InitialState = {
|
||||||
|
readonly discovery: "idle" | "pending" | "succeeded" | "failed"
|
||||||
|
readonly pending: number
|
||||||
|
readonly succeeded: number
|
||||||
|
readonly failed: number
|
||||||
|
readonly version: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Selection =
|
||||||
|
| { readonly _tag: "pending" }
|
||||||
|
| { readonly _tag: "selected"; readonly model: ModelV2.Info }
|
||||||
|
| { readonly _tag: "absent" }
|
||||||
|
| { readonly _tag: "incomplete" }
|
||||||
|
|
||||||
export type Draft = {
|
export type Draft = {
|
||||||
provider: {
|
provider: {
|
||||||
list: () => readonly ProviderRecord[]
|
list: () => readonly ProviderRecord[]
|
||||||
@@ -42,6 +63,10 @@ export type Draft = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface extends State.Transformable<Draft> {
|
export interface Interface extends State.Transformable<Draft> {
|
||||||
|
readonly initial: {
|
||||||
|
readonly discover: <A, E, R>(work: Effect.Effect<A, E, R>) => Effect.Effect<void, never, R>
|
||||||
|
readonly source: <A, E, R>(work: Effect.Effect<A, E, R>) => Effect.Effect<void, never, R | Scope.Scope>
|
||||||
|
}
|
||||||
readonly provider: {
|
readonly provider: {
|
||||||
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
|
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
|
||||||
readonly all: () => Effect.Effect<ProviderV2.Info[]>
|
readonly all: () => Effect.Effect<ProviderV2.Info[]>
|
||||||
@@ -51,6 +76,10 @@ export interface Interface extends State.Transformable<Draft> {
|
|||||||
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 | undefined>
|
||||||
readonly all: () => Effect.Effect<ModelV2.Info[]>
|
readonly all: () => Effect.Effect<ModelV2.Info[]>
|
||||||
readonly available: () => Effect.Effect<ModelV2.Info[]>
|
readonly available: () => Effect.Effect<ModelV2.Info[]>
|
||||||
|
readonly select: (
|
||||||
|
ref: ModelV2.Ref | undefined,
|
||||||
|
supported: (model: ModelV2.Info) => boolean,
|
||||||
|
) => Effect.Effect<ModelV2.Info | undefined, IncompleteError>
|
||||||
readonly default: () => Effect.Effect<ModelV2.Info | undefined>
|
readonly default: () => Effect.Effect<ModelV2.Info | undefined>
|
||||||
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
|
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
|
||||||
}
|
}
|
||||||
@@ -63,6 +92,67 @@ const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
|
const initial = yield* SubscriptionRef.make<InitialState>({
|
||||||
|
discovery: "idle",
|
||||||
|
pending: 0,
|
||||||
|
succeeded: 0,
|
||||||
|
failed: 0,
|
||||||
|
version: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateInitial = (update: (state: InitialState) => Omit<InitialState, "version">) =>
|
||||||
|
SubscriptionRef.update(initial, (state) => ({ ...update(state), version: state.version + 1 }))
|
||||||
|
|
||||||
|
const discover: Interface["initial"]["discover"] = Effect.fn("CatalogV2.initial.discover")(function* (work) {
|
||||||
|
yield* Effect.acquireUseRelease(
|
||||||
|
SubscriptionRef.modify(initial, (state) => [
|
||||||
|
state.discovery === "idle",
|
||||||
|
state.discovery === "idle"
|
||||||
|
? { ...state, discovery: "pending" as const, version: state.version + 1 }
|
||||||
|
: state,
|
||||||
|
]),
|
||||||
|
() => work,
|
||||||
|
(tracked, exit) =>
|
||||||
|
tracked
|
||||||
|
? updateInitial((state) => ({
|
||||||
|
discovery: Exit.isSuccess(exit) ? "succeeded" : "failed",
|
||||||
|
pending: state.pending,
|
||||||
|
succeeded: state.succeeded,
|
||||||
|
failed: state.failed,
|
||||||
|
}))
|
||||||
|
: Effect.void,
|
||||||
|
).pipe(
|
||||||
|
Effect.tapCause((cause) => Effect.logError("initial catalog discovery failed", { cause })),
|
||||||
|
Effect.ignoreCause,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const source: Interface["initial"]["source"] = Effect.fn("CatalogV2.initial.source")(function* (work) {
|
||||||
|
yield* Effect.uninterruptibleMask((restore) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const tracked = yield* SubscriptionRef.modify(initial, (state) => [
|
||||||
|
state.discovery === "pending",
|
||||||
|
state.discovery === "pending"
|
||||||
|
? { ...state, pending: state.pending + 1, version: state.version + 1 }
|
||||||
|
: state,
|
||||||
|
])
|
||||||
|
yield* restore(work).pipe(
|
||||||
|
tracked
|
||||||
|
? Effect.onExit((exit) =>
|
||||||
|
updateInitial((state) => ({
|
||||||
|
discovery: state.discovery,
|
||||||
|
pending: state.pending - 1,
|
||||||
|
succeeded: state.succeeded + (Exit.isSuccess(exit) ? 1 : 0),
|
||||||
|
failed: state.failed + (Exit.isFailure(exit) ? 1 : 0),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
: (effect) => effect,
|
||||||
|
Effect.ignoreCause,
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
|
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
|
||||||
if (provider.disabled) return false
|
if (provider.disabled) return false
|
||||||
@@ -157,11 +247,17 @@ const layer = Layer.effect(
|
|||||||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
|
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
|
||||||
yield* events.publish(Event.Updated, {})
|
yield* events.publish(Event.Updated, {})
|
||||||
}),
|
}),
|
||||||
|
committed: () => updateInitial((state) => state),
|
||||||
})
|
})
|
||||||
const result: Interface = {
|
const result: Interface = {
|
||||||
transform: state.transform,
|
transform: state.transform,
|
||||||
reload: state.reload,
|
reload: state.reload,
|
||||||
|
|
||||||
|
initial: {
|
||||||
|
discover,
|
||||||
|
source,
|
||||||
|
},
|
||||||
|
|
||||||
provider: {
|
provider: {
|
||||||
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
||||||
return state.get().providers.get(providerID)?.provider
|
return state.get().providers.get(providerID)?.provider
|
||||||
@@ -202,6 +298,45 @@ const layer = Layer.effect(
|
|||||||
return (yield* result.model.all()).filter((model) => providers.has(model.providerID) && model.enabled)
|
return (yield* result.model.all()).filter((model) => providers.has(model.providerID) && model.enabled)
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
select: Effect.fn("CatalogV2.model.select")(function* (ref, supported) {
|
||||||
|
const inspect: () => Effect.Effect<Selection> = Effect.fnUntraced(function* () {
|
||||||
|
const status = yield* SubscriptionRef.get(initial)
|
||||||
|
|
||||||
|
if (ref) {
|
||||||
|
const selected = (yield* result.model.available()).find(
|
||||||
|
(model) => model.providerID === ref.providerID && model.id === ref.id,
|
||||||
|
)
|
||||||
|
if (selected && status.discovery !== "idle" && status.discovery !== "pending")
|
||||||
|
return { _tag: "selected", model: selected }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.discovery === "idle" || status.discovery === "pending") return { _tag: "pending" }
|
||||||
|
if (status.pending > 0) return { _tag: "pending" }
|
||||||
|
if (status.discovery === "failed" || status.failed > 0) return { _tag: "incomplete" }
|
||||||
|
if (ref) return { _tag: "absent" }
|
||||||
|
|
||||||
|
const defaultModel = yield* result.model.default()
|
||||||
|
if (defaultModel && supported(defaultModel)) return { _tag: "selected", model: defaultModel }
|
||||||
|
const selected = (yield* result.model.available()).find(supported)
|
||||||
|
return selected ? { _tag: "selected", model: selected } : { _tag: "absent" }
|
||||||
|
})
|
||||||
|
const resolve = (selection: Selection): Effect.Effect<ModelV2.Info | undefined, IncompleteError> => {
|
||||||
|
if (selection._tag === "selected") return Effect.succeed(selection.model)
|
||||||
|
if (selection._tag === "incomplete") return Effect.fail(new IncompleteError())
|
||||||
|
return Effect.succeed(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const observed = yield* SubscriptionRef.changes(initial).pipe(
|
||||||
|
Stream.mapEffect(() => inspect()),
|
||||||
|
Stream.filter((selection) => selection._tag !== "pending"),
|
||||||
|
Stream.runHead,
|
||||||
|
)
|
||||||
|
return yield* Option.match(observed, {
|
||||||
|
onNone: () => Effect.die(new Error("Catalog readiness stream ended before initial settlement")),
|
||||||
|
onSome: resolve,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
default: Effect.fn("CatalogV2.model.default")(function* () {
|
default: Effect.fn("CatalogV2.model.default")(function* () {
|
||||||
const defaultModel = state.get().defaultModel
|
const defaultModel = state.get().defaultModel
|
||||||
if (defaultModel) {
|
if (defaultModel) {
|
||||||
|
|||||||
@@ -104,8 +104,9 @@ const layer = Layer.effectDiscard(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const sdkPlugins = yield* SdkPlugins.Service
|
const sdkPlugins = yield* SdkPlugins.Service
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
const services = Context.mergeAll(
|
const services = Context.mergeAll(
|
||||||
Context.make(Catalog.Service, yield* Catalog.Service),
|
Context.make(Catalog.Service, catalog),
|
||||||
Context.make(CommandV2.Service, yield* CommandV2.Service),
|
Context.make(CommandV2.Service, yield* CommandV2.Service),
|
||||||
Context.make(Integration.Service, yield* Integration.Service),
|
Context.make(Integration.Service, yield* Integration.Service),
|
||||||
Context.make(AgentV2.Service, yield* AgentV2.Service),
|
Context.make(AgentV2.Service, yield* AgentV2.Service),
|
||||||
@@ -139,37 +140,42 @@ const layer = Layer.effectDiscard(
|
|||||||
input.effect(context).pipe(Effect.provide(services)),
|
input.effect(context).pipe(Effect.provide(services)),
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* State.batch(
|
yield* catalog.initial.discover(
|
||||||
Effect.gen(function* () {
|
State.batch(
|
||||||
yield* add(ConfigReferencePlugin.Plugin)
|
Effect.gen(function* () {
|
||||||
yield* add(AgentPlugin.Plugin)
|
yield* add(ConfigReferencePlugin.Plugin)
|
||||||
yield* add(CommandPlugin.Plugin)
|
yield* add(AgentPlugin.Plugin)
|
||||||
yield* add(SkillPlugin.Plugin)
|
yield* add(CommandPlugin.Plugin)
|
||||||
yield* add(ModelsDevPlugin)
|
yield* add(SkillPlugin.Plugin)
|
||||||
yield* add(ConfigExternalPlugin.Plugin)
|
yield* add(ModelsDevPlugin)
|
||||||
yield* add(ApplyPatchTool.Plugin)
|
yield* add(ConfigExternalPlugin.Plugin)
|
||||||
yield* add(EditTool.Plugin)
|
yield* add(ApplyPatchTool.Plugin)
|
||||||
yield* add(GlobTool.Plugin)
|
yield* add(EditTool.Plugin)
|
||||||
yield* add(GrepTool.Plugin)
|
yield* add(GlobTool.Plugin)
|
||||||
yield* add(QuestionTool.Plugin)
|
yield* add(GrepTool.Plugin)
|
||||||
yield* add(ReadTool.Plugin)
|
yield* add(QuestionTool.Plugin)
|
||||||
yield* add(ShellTool.Plugin)
|
yield* add(ReadTool.Plugin)
|
||||||
yield* add(SkillTool.Plugin)
|
yield* add(ShellTool.Plugin)
|
||||||
yield* add(SubagentTool.Plugin)
|
yield* add(SkillTool.Plugin)
|
||||||
yield* add(TodoWriteTool.Plugin)
|
yield* add(SubagentTool.Plugin)
|
||||||
yield* add(WebFetchTool.Plugin)
|
yield* add(TodoWriteTool.Plugin)
|
||||||
yield* add(WebSearchTool.Plugin)
|
yield* add(WebFetchTool.Plugin)
|
||||||
yield* add(WriteTool.Plugin)
|
yield* add(WebSearchTool.Plugin)
|
||||||
yield* add(ConfigAgentPlugin.Plugin)
|
yield* add(WriteTool.Plugin)
|
||||||
yield* add(ConfigCommandPlugin.Plugin)
|
yield* add(ConfigAgentPlugin.Plugin)
|
||||||
yield* add(ConfigSkillPlugin.Plugin)
|
yield* add(ConfigCommandPlugin.Plugin)
|
||||||
for (const item of ProviderPlugins) yield* add(item)
|
yield* add(ConfigSkillPlugin.Plugin)
|
||||||
yield* add(ConfigProviderPlugin.Plugin)
|
for (const item of ProviderPlugins) yield* add(item)
|
||||||
yield* add(VariantPlugin.Plugin)
|
yield* add(ConfigProviderPlugin.Plugin)
|
||||||
// Embedder-contributed plugins are added last so they layer over config.
|
yield* add(VariantPlugin.Plugin)
|
||||||
for (const plugin of sdkPlugins.all()) yield* add(plugin)
|
// Embedder-contributed plugins are added last so they layer over config.
|
||||||
}),
|
for (const plugin of sdkPlugins.all()) yield* add(plugin)
|
||||||
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
}),
|
||||||
|
),
|
||||||
|
).pipe(
|
||||||
|
Effect.withSpan("PluginInternal.boot"),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { define } from "./internal"
|
import { define } from "./internal"
|
||||||
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
|
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
|
import { Catalog } from "../catalog"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { ModelV2 } from "../model"
|
import { ModelV2 } from "../model"
|
||||||
import { ModelsDev } from "../models-dev"
|
import { ModelsDev } from "../models-dev"
|
||||||
@@ -199,9 +200,10 @@ function applyModel(
|
|||||||
export const ModelsDevPlugin = define({
|
export const ModelsDevPlugin = define({
|
||||||
id: "models-dev",
|
id: "models-dev",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
const modelsDev = yield* ModelsDev.Service
|
const modelsDev = yield* ModelsDev.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const loaded = { data: yield* modelsDev.get() }
|
const loaded: { data: Record<string, ModelsDev.Provider> } = { data: {} }
|
||||||
yield* ctx.integration.transform((integrations) => {
|
yield* ctx.integration.transform((integrations) => {
|
||||||
for (const item of Object.values(loaded.data)) {
|
for (const item of Object.values(loaded.data)) {
|
||||||
if (item.env.length === 0) continue
|
if (item.env.length === 0) continue
|
||||||
@@ -252,15 +254,16 @@ export const ModelsDevPlugin = define({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const refresh = modelsDev.get().pipe(
|
||||||
|
Effect.tap((data) => Effect.sync(() => (loaded.data = data))),
|
||||||
|
Effect.andThen(ctx.integration.reload()),
|
||||||
|
Effect.andThen(ctx.catalog.reload()),
|
||||||
|
Effect.tapCause((cause) => Effect.logError("failed to load models.dev catalog", { cause })),
|
||||||
|
)
|
||||||
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
|
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||||
Stream.runForEach(() =>
|
Stream.runForEach(() => refresh.pipe(Effect.ignoreCause)),
|
||||||
modelsDev.get().pipe(
|
|
||||||
Effect.tap((data) => Effect.sync(() => (loaded.data = data))),
|
|
||||||
Effect.andThen(ctx.integration.reload()),
|
|
||||||
Effect.andThen(ctx.catalog.reload()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
|
yield* catalog.initial.source(refresh)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
|||||||
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||||
import type { Scope } from "effect"
|
import type { Scope } from "effect"
|
||||||
import { Credential } from "../../credential"
|
import { Credential } from "../../credential"
|
||||||
|
import { Catalog } from "../../catalog"
|
||||||
import { EventV2 } from "../../event"
|
import { EventV2 } from "../../event"
|
||||||
import { InstallationVersion } from "../../installation/version"
|
import { InstallationVersion } from "../../installation/version"
|
||||||
import { Integration } from "../../integration"
|
import { Integration } from "../../integration"
|
||||||
@@ -162,14 +163,13 @@ export const OpenAIPlugin = define({
|
|||||||
id: "openai",
|
id: "openai",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
const loading = Semaphore.makeUnsafe(1)
|
const loading = Semaphore.makeUnsafe(1)
|
||||||
let chatgpt = false
|
let chatgpt = false
|
||||||
|
|
||||||
const load = Effect.fn("OpenAIPlugin.load")(function* () {
|
const load = Effect.fn("OpenAIPlugin.load")(function* () {
|
||||||
const connection = yield* ctx.integration.connection.active("openai")
|
const connection = yield* ctx.integration.connection.active("openai")
|
||||||
const credential = connection
|
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
|
||||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
: undefined
|
|
||||||
chatgpt = OpenAICodex.isChatGPT(credential)
|
chatgpt = OpenAICodex.isChatGPT(credential)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -204,13 +204,16 @@ export const OpenAIPlugin = define({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
const refresh = () =>
|
||||||
|
loading
|
||||||
|
.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||||
|
.pipe(Effect.tapCause((cause) => Effect.logWarning("failed to refresh OpenAI catalog", { cause })))
|
||||||
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
|
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
|
||||||
Stream.runForEach(refresh),
|
Stream.runForEach(() => refresh().pipe(Effect.ignoreCause)),
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
yield* refresh().pipe(Effect.forkScoped)
|
yield* catalog.initial.source(refresh())
|
||||||
yield* ctx.aisdk.sdk(
|
yield* ctx.aisdk.sdk(
|
||||||
Effect.fn(function* (evt) {
|
Effect.fn(function* (evt) {
|
||||||
if (evt.package !== "@ai-sdk/openai") return
|
if (evt.package !== "@ai-sdk/openai") return
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
|||||||
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
|
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
|
||||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { EventV2 } from "../../event"
|
import { EventV2 } from "../../event"
|
||||||
|
import { Catalog } from "../../catalog"
|
||||||
import { Credential } from "../../credential"
|
import { Credential } from "../../credential"
|
||||||
import { Integration } from "../../integration"
|
import { Integration } from "../../integration"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
@@ -74,10 +75,11 @@ function oauth(http: HttpClient.HttpClient) {
|
|||||||
} satisfies IntegrationOAuthMethodRegistration
|
} satisfies IntegrationOAuthMethodRegistration
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Scope.Scope>({
|
export const OpencodePlugin = define<Catalog.Service | HttpClient.HttpClient | EventV2.Service | Scope.Scope>({
|
||||||
id: "opencode",
|
id: "opencode",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
const http = yield* HttpClient.HttpClient
|
const http = yield* HttpClient.HttpClient
|
||||||
const loading = Semaphore.makeUnsafe(1)
|
const loading = Semaphore.makeUnsafe(1)
|
||||||
let connected = false
|
let connected = false
|
||||||
@@ -89,13 +91,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
|||||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
: undefined
|
: undefined
|
||||||
connected = connection !== undefined
|
connected = connection !== undefined
|
||||||
providers = credential
|
providers = credential ? yield* fetchProviders(http, credential) : undefined
|
||||||
? yield* fetchProviders(http, credential).pipe(
|
|
||||||
Effect.catch((cause) =>
|
|
||||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* ctx.integration.transform((draft) => {
|
yield* ctx.integration.transform((draft) => {
|
||||||
@@ -182,13 +178,16 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
const refresh = () =>
|
||||||
|
loading
|
||||||
|
.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||||
|
.pipe(Effect.tapCause((cause) => Effect.logWarning("failed to load OpenCode provider config", { cause })))
|
||||||
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
|
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
|
||||||
Stream.runForEach(refresh),
|
Stream.runForEach(() => refresh().pipe(Effect.ignoreCause)),
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
yield* refresh().pipe(Effect.forkScoped)
|
yield* catalog.initial.source(refresh())
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiE
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Error =
|
export type Error =
|
||||||
|
| Catalog.IncompleteError
|
||||||
| ModelNotSelectedError
|
| ModelNotSelectedError
|
||||||
| ModelUnavailableError
|
| ModelUnavailableError
|
||||||
| VariantUnavailableError
|
| VariantUnavailableError
|
||||||
@@ -234,15 +235,7 @@ const layer = Layer.effect(
|
|||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
return Service.of({
|
return Service.of({
|
||||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
||||||
// Location plugins populate and filter the catalog asynchronously during layer startup.
|
const selected = yield* catalog.model.select(session.model, supported)
|
||||||
const defaultModel = session.model ? undefined : yield* catalog.model.default()
|
|
||||||
const selected = session.model
|
|
||||||
? (yield* catalog.model.available()).find(
|
|
||||||
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
|
|
||||||
)
|
|
||||||
: defaultModel && supported(defaultModel)
|
|
||||||
? defaultModel
|
|
||||||
: (yield* catalog.model.available()).find(supported)
|
|
||||||
if (!selected && session.model)
|
if (!selected && session.model)
|
||||||
return yield* new ModelUnavailableError({
|
return yield* new ModelUnavailableError({
|
||||||
providerID: session.model.providerID,
|
providerID: session.model.providerID,
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export interface Options<State, DraftApi> {
|
|||||||
readonly draft: MakeDraft<State, DraftApi>
|
readonly draft: MakeDraft<State, DraftApi>
|
||||||
/** Runs after all active transforms and before the rebuilt state becomes visible. */
|
/** Runs after all active transforms and before the rebuilt state becomes visible. */
|
||||||
readonly finalize?: (draft: DraftApi) => Effect.Effect<void>
|
readonly finalize?: (draft: DraftApi) => Effect.Effect<void>
|
||||||
|
/** Runs after the rebuilt state becomes visible. */
|
||||||
|
readonly committed?: () => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
|
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
|
||||||
@@ -67,6 +69,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
|||||||
const api = options.draft(next)
|
const api = options.draft(next)
|
||||||
if (options.finalize) yield* options.finalize(api)
|
if (options.finalize) yield* options.finalize(api)
|
||||||
state = next
|
state = next
|
||||||
|
if (options.committed) yield* options.committed()
|
||||||
})
|
})
|
||||||
|
|
||||||
const apply = (transform: TransformCallback<DraftApi>, draft: DraftApi) =>
|
const apply = (transform: TransformCallback<DraftApi>, draft: DraftApi) =>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
@@ -9,7 +9,10 @@ import { EventV2 } from "@opencode-ai/core/event"
|
|||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
|
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||||
import { location } from "./fixture/location"
|
import { location } from "./fixture/location"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
@@ -27,8 +30,341 @@ const catalogLayer = AppNodeBuilder.build(
|
|||||||
[[Location.node, locationLayer]],
|
[[Location.node, locationLayer]],
|
||||||
)
|
)
|
||||||
const it = testEffect(catalogLayer)
|
const it = testEffect(catalogLayer)
|
||||||
|
const sessionModelLayer = AppNodeBuilder.build(
|
||||||
|
LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, SessionRunnerModel.node]),
|
||||||
|
[[Location.node, locationLayer]],
|
||||||
|
)
|
||||||
|
const sessionModelIt = testEffect(sessionModelLayer)
|
||||||
|
|
||||||
|
const session = (providerID?: ProviderV2.ID, modelID?: ModelV2.ID) =>
|
||||||
|
SessionV2.Info.make({
|
||||||
|
id: SessionV2.ID.make("ses_catalog_readiness"),
|
||||||
|
projectID: ProjectV2.ID.global,
|
||||||
|
title: "test",
|
||||||
|
...(providerID === undefined || modelID === undefined ? {} : { model: { providerID, id: modelID } }),
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||||
|
location: { directory: AbsolutePath.make("test") },
|
||||||
|
})
|
||||||
|
|
||||||
|
const addSupportedModel = (catalog: Catalog.Interface, providerID: ProviderV2.ID, modelID: ModelV2.ID) =>
|
||||||
|
catalog.transform((draft) => {
|
||||||
|
draft.provider.update(providerID, (provider) => {
|
||||||
|
provider.api = { type: "aisdk", package: "@ai-sdk/openai", settings: {} }
|
||||||
|
})
|
||||||
|
draft.model.update(providerID, modelID, () => {})
|
||||||
|
})
|
||||||
|
|
||||||
describe("CatalogV2", () => {
|
describe("CatalogV2", () => {
|
||||||
|
it.effect("keeps available snapshots nonblocking during initial catalog work", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
yield* catalog.initial.discover(catalog.initial.source(Deferred.await(release)))
|
||||||
|
|
||||||
|
expect(yield* catalog.model.available()).toEqual([])
|
||||||
|
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("waits until an explicit model appears", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const providerID = ProviderV2.ID.make("delayed-provider")
|
||||||
|
const modelID = ModelV2.ID.make("delayed-model")
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const completed = yield* Deferred.make<ModelV2.Info | undefined>()
|
||||||
|
yield* catalog.initial.discover(
|
||||||
|
catalog.initial.source(
|
||||||
|
Deferred.await(release).pipe(Effect.andThen(addSupportedModel(catalog, providerID, modelID))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const selected = yield* catalog.model
|
||||||
|
.select(ModelV2.Ref.make({ providerID, id: modelID }), () => true)
|
||||||
|
.pipe(
|
||||||
|
Effect.tap((model) => Deferred.succeed(completed, model)),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(yield* Deferred.isDone(completed)).toBe(false)
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
|
||||||
|
expect((yield* Fiber.join(selected))?.id).toBe(modelID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("waits for discovery contributors before returning an existing explicit model", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const providerID = ProviderV2.ID.make("overlaid-provider")
|
||||||
|
const modelID = ModelV2.ID.make("overlaid-model")
|
||||||
|
const modelVisible = yield* Deferred.make<void>()
|
||||||
|
const releaseOverlay = yield* Deferred.make<void>()
|
||||||
|
const completed = yield* Deferred.make<ModelV2.Info | undefined>()
|
||||||
|
yield* catalog.initial
|
||||||
|
.discover(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* addSupportedModel(catalog, providerID, modelID)
|
||||||
|
yield* Deferred.succeed(modelVisible, undefined)
|
||||||
|
yield* Deferred.await(releaseOverlay)
|
||||||
|
yield* catalog.transform((draft) =>
|
||||||
|
draft.model.update(providerID, modelID, (model) => {
|
||||||
|
model.name = "Configured model"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||||
|
yield* Deferred.await(modelVisible)
|
||||||
|
const selected = yield* catalog.model
|
||||||
|
.select(ModelV2.Ref.make({ providerID, id: modelID }), () => true)
|
||||||
|
.pipe(
|
||||||
|
Effect.tap((model) => Deferred.succeed(completed, model)),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(yield* Deferred.isDone(completed)).toBe(false)
|
||||||
|
yield* Deferred.succeed(releaseOverlay, undefined)
|
||||||
|
|
||||||
|
expect((yield* Fiber.join(selected))?.name).toBe("Configured model")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("selects an explicit model as soon as it arrives without waiting for unrelated initial work", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const providerID = ProviderV2.ID.make("ready-provider")
|
||||||
|
const modelID = ModelV2.ID.make("ready-model")
|
||||||
|
const releaseModel = yield* Deferred.make<void>()
|
||||||
|
const releaseUnrelated = yield* Deferred.make<void>()
|
||||||
|
yield* catalog.initial.discover(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* catalog.initial.source(
|
||||||
|
Deferred.await(releaseModel).pipe(Effect.andThen(addSupportedModel(catalog, providerID, modelID))),
|
||||||
|
)
|
||||||
|
yield* catalog.initial.source(Deferred.await(releaseUnrelated))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const selected = yield* catalog.model
|
||||||
|
.select(ModelV2.Ref.make({ providerID, id: modelID }), () => true)
|
||||||
|
.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||||
|
|
||||||
|
yield* Deferred.succeed(releaseModel, undefined)
|
||||||
|
|
||||||
|
expect((yield* Fiber.join(selected))?.id).toBe(modelID)
|
||||||
|
expect(yield* Deferred.isDone(releaseUnrelated)).toBe(false)
|
||||||
|
yield* Deferred.succeed(releaseUnrelated, undefined)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("waits for a pending source after another initial source fails", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const providerID = ProviderV2.ID.make("recovering-provider")
|
||||||
|
const modelID = ModelV2.ID.make("recovering-model")
|
||||||
|
const releaseModel = yield* Deferred.make<void>()
|
||||||
|
const completed = yield* Deferred.make<ModelV2.Info | undefined>()
|
||||||
|
yield* catalog.initial.discover(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* catalog.initial.source(Effect.fail(new Error("unrelated source failed")))
|
||||||
|
yield* catalog.initial.source(
|
||||||
|
Deferred.await(releaseModel).pipe(Effect.andThen(addSupportedModel(catalog, providerID, modelID))),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const selected = yield* catalog.model
|
||||||
|
.select(ModelV2.Ref.make({ providerID, id: modelID }), () => true)
|
||||||
|
.pipe(
|
||||||
|
Effect.tap((model) => Deferred.succeed(completed, model)),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(yield* Deferred.isDone(completed)).toBe(false)
|
||||||
|
yield* Deferred.succeed(releaseModel, undefined)
|
||||||
|
|
||||||
|
expect((yield* Fiber.join(selected))?.id).toBe(modelID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("waits for a pending source after discovery fails", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const providerID = ProviderV2.ID.make("discovery-failure-provider")
|
||||||
|
const modelID = ModelV2.ID.make("discovery-failure-model")
|
||||||
|
const releaseModel = yield* Deferred.make<void>()
|
||||||
|
const completed = yield* Deferred.make<ModelV2.Info | undefined>()
|
||||||
|
yield* catalog.initial.discover(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* catalog.initial.source(
|
||||||
|
Deferred.await(releaseModel).pipe(Effect.andThen(addSupportedModel(catalog, providerID, modelID))),
|
||||||
|
)
|
||||||
|
return yield* Effect.fail(new Error("discovery failed after source registration"))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const selected = yield* catalog.model
|
||||||
|
.select(ModelV2.Ref.make({ providerID, id: modelID }), () => true)
|
||||||
|
.pipe(
|
||||||
|
Effect.tap((model) => Deferred.succeed(completed, model)),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(yield* Deferred.isDone(completed)).toBe(false)
|
||||||
|
yield* Deferred.succeed(releaseModel, undefined)
|
||||||
|
|
||||||
|
expect((yield* Fiber.join(selected))?.id).toBe(modelID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("observes discovery settlement completed before lookup subscribes", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
yield* catalog.initial.discover(Effect.void)
|
||||||
|
|
||||||
|
const selected = yield* catalog.model.select(
|
||||||
|
ModelV2.Ref.make({ providerID: ProviderV2.ID.make("missing"), id: ModelV2.ID.make("missing") }),
|
||||||
|
() => true,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(selected).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("waits for initial settlement before choosing an omitted model", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const earlyProviderID = ProviderV2.ID.make("early-provider")
|
||||||
|
const earlyModelID = ModelV2.ID.make("early-model")
|
||||||
|
const finalProviderID = ProviderV2.ID.make("final-provider")
|
||||||
|
const finalModelID = ModelV2.ID.make("final-model")
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const completed = yield* Deferred.make<ModelV2.Info | undefined>()
|
||||||
|
yield* addSupportedModel(catalog, earlyProviderID, earlyModelID)
|
||||||
|
yield* catalog.initial.discover(
|
||||||
|
catalog.initial.source(
|
||||||
|
Deferred.await(release).pipe(
|
||||||
|
Effect.andThen(
|
||||||
|
catalog.transform((draft) => {
|
||||||
|
draft.provider.update(finalProviderID, (provider) => {
|
||||||
|
provider.api = { type: "aisdk", package: "@ai-sdk/openai", settings: {} }
|
||||||
|
})
|
||||||
|
draft.model.update(finalProviderID, finalModelID, () => {})
|
||||||
|
draft.model.default.set(finalProviderID, finalModelID)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const selected = yield* catalog.model
|
||||||
|
.select(undefined, () => true)
|
||||||
|
.pipe(
|
||||||
|
Effect.tap((model) => Deferred.succeed(completed, model)),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(yield* Deferred.isDone(completed)).toBe(false)
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
|
||||||
|
expect((yield* Fiber.join(selected))?.id).toBe(finalModelID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
sessionModelIt.effect("reports an unavailable explicit model after successful initial settlement", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const models = yield* SessionRunnerModel.Service
|
||||||
|
const providerID = ProviderV2.ID.make("missing")
|
||||||
|
const modelID = ModelV2.ID.make("missing")
|
||||||
|
yield* catalog.initial.discover(Effect.void)
|
||||||
|
|
||||||
|
const failure = yield* models.resolve(session(providerID, modelID)).pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
_tag: "SessionRunnerModel.ModelUnavailableError",
|
||||||
|
providerID,
|
||||||
|
modelID,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
sessionModelIt.effect("reports catalog incompleteness when a source fails after discovery settles", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const models = yield* SessionRunnerModel.Service
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const completed = yield* Deferred.make<SessionRunnerModel.Error>()
|
||||||
|
yield* catalog.initial.discover(
|
||||||
|
catalog.initial.source(
|
||||||
|
Deferred.await(release).pipe(Effect.andThen(Effect.fail(new Error("initial source failed")))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const result = yield* models
|
||||||
|
.resolve(session(ProviderV2.ID.make("missing"), ModelV2.ID.make("missing")))
|
||||||
|
.pipe(
|
||||||
|
Effect.flip,
|
||||||
|
Effect.tap((failure) => Deferred.succeed(completed, failure)),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(yield* Deferred.isDone(completed)).toBe(false)
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
|
||||||
|
const failure = yield* Fiber.join(result)
|
||||||
|
|
||||||
|
expect(failure).toMatchObject({ _tag: "Catalog.IncompleteError" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("settles interrupted initial sources as incomplete", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const sourceScope = yield* Scope.make()
|
||||||
|
const started = yield* Deferred.make<void>()
|
||||||
|
yield* catalog.initial.discover(
|
||||||
|
catalog.initial
|
||||||
|
.source(Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)))
|
||||||
|
.pipe(Scope.provide(sourceScope)),
|
||||||
|
)
|
||||||
|
yield* Deferred.await(started)
|
||||||
|
|
||||||
|
yield* Scope.close(sourceScope, Exit.void)
|
||||||
|
|
||||||
|
const failure = yield* catalog.model
|
||||||
|
.select(
|
||||||
|
ModelV2.Ref.make({ providerID: ProviderV2.ID.make("missing"), id: ModelV2.ID.make("missing") }),
|
||||||
|
() => true,
|
||||||
|
)
|
||||||
|
.pipe(Effect.flip)
|
||||||
|
expect(failure).toMatchObject({ _tag: "Catalog.IncompleteError" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
sessionModelIt.effect("preserves the supported-model fallback after initial settlement", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const models = yield* SessionRunnerModel.Service
|
||||||
|
const unsupportedProviderID = ProviderV2.ID.make("unsupported-provider")
|
||||||
|
const unsupportedModelID = ModelV2.ID.make("unsupported-model")
|
||||||
|
const supportedProviderID = ProviderV2.ID.make("supported-provider")
|
||||||
|
const supportedModelID = ModelV2.ID.make("supported-model")
|
||||||
|
yield* catalog.transform((draft) => {
|
||||||
|
draft.provider.update(unsupportedProviderID, () => {})
|
||||||
|
draft.model.update(unsupportedProviderID, unsupportedModelID, () => {})
|
||||||
|
draft.model.default.set(unsupportedProviderID, unsupportedModelID)
|
||||||
|
draft.provider.update(supportedProviderID, (provider) => {
|
||||||
|
provider.api = { type: "aisdk", package: "@ai-sdk/openai", settings: {} }
|
||||||
|
})
|
||||||
|
draft.model.update(supportedProviderID, supportedModelID, () => {})
|
||||||
|
})
|
||||||
|
yield* catalog.initial.discover(Effect.void)
|
||||||
|
|
||||||
|
const selected = yield* models.resolve(session())
|
||||||
|
|
||||||
|
expect(selected.ref).toMatchObject({ providerID: supportedProviderID, id: supportedModelID })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("publishes an updated event after catalog changes", () =>
|
it.effect("publishes an updated event after catalog changes", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { DateTime, Effect, Equal, Hash, Schema } from "effect"
|
import { DateTime, Deferred, Effect, Equal, Hash, Schema } from "effect"
|
||||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
import { buildLocationServiceMap, LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
@@ -16,6 +16,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
|||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||||
|
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { toolDefinitions, waitForTool } from "./lib/tool"
|
import { toolDefinitions, waitForTool } from "./lib/tool"
|
||||||
@@ -27,6 +28,53 @@ import { ToolRegistry } from "../src/tool/registry"
|
|||||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])))
|
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])))
|
||||||
|
|
||||||
describe("LocationServiceMap", () => {
|
describe("LocationServiceMap", () => {
|
||||||
|
it.live("acquires location services while initial catalog discovery is blocked", () =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const started = yield* Deferred.make<void>()
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const store = SdkPlugins.makeStore()
|
||||||
|
store.plugins.set(
|
||||||
|
"blocked-catalog-source",
|
||||||
|
define({
|
||||||
|
id: "blocked-catalog-source",
|
||||||
|
effect: (ctx) =>
|
||||||
|
ctx.catalog
|
||||||
|
.transform((draft) => draft.provider.update("blocked-catalog-source", () => {}))
|
||||||
|
.pipe(
|
||||||
|
Effect.andThen(Deferred.succeed(started, undefined)),
|
||||||
|
Effect.andThen(Deferred.await(release)),
|
||||||
|
Effect.asVoid,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return yield* Effect.gen(function* () {
|
||||||
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
const context = yield* locations.contextEffect(
|
||||||
|
Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
|
||||||
|
)
|
||||||
|
yield* Deferred.await(started)
|
||||||
|
|
||||||
|
const snapshot = yield* Catalog.Service.use((catalog) => catalog.model.available()).pipe(
|
||||||
|
Effect.provide(context),
|
||||||
|
)
|
||||||
|
expect(Array.isArray(snapshot)).toBe(true)
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(buildLocationServiceMap([[SdkPlugins.node, SdkPlugins.layerWithStore(store)]]), {
|
||||||
|
local: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("reuses cached services for constructed and decoded location refs", () =>
|
it.live("reuses cached services for constructed and decoded location refs", () =>
|
||||||
Effect.acquireRelease(
|
Effect.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|||||||
@@ -56,6 +56,26 @@ describe("State", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("runs committed after the rebuilt state is visible", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
let visible: string[] = []
|
||||||
|
const state = State.create({
|
||||||
|
initial: () => ({ values: [] as string[] }),
|
||||||
|
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||||
|
committed: () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
visible = [...state.get().values]
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* state.transform((editor) => {
|
||||||
|
editor.add("committed")
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(visible).toEqual(["committed"])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("disposes a transform once and rebuilds remaining state", () =>
|
it.effect("disposes a transform once and rebuilds remaining state", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const state = State.create({
|
const state = State.create({
|
||||||
|
|||||||
Reference in New Issue
Block a user