mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 03:18:31 +00:00
fix(core): wait for initial catalog readiness
This commit is contained in:
parent
e2faeb84e5
commit
e69f3af711
10 changed files with 616 additions and 70 deletions
|
|
@ -1,7 +1,8 @@
|
|||
export * as Catalog from "./catalog"
|
||||
|
||||
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 { ModelV2 } from "./model"
|
||||
import { ProviderV2 } from "./provider"
|
||||
|
|
@ -18,11 +19,31 @@ export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
|
|||
|
||||
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 = {
|
||||
providers: Map<ProviderV2.ID, ProviderRecord>
|
||||
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 = {
|
||||
provider: {
|
||||
list: () => readonly ProviderRecord[]
|
||||
|
|
@ -42,6 +63,10 @@ export type 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 get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
|
||||
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 all: () => 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 small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
|
||||
}
|
||||
|
|
@ -63,6 +92,67 @@ const layer = Layer.effect(
|
|||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.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) => {
|
||||
if (provider.disabled) return false
|
||||
|
|
@ -157,11 +247,17 @@ const layer = Layer.effect(
|
|||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
committed: () => updateInitial((state) => state),
|
||||
})
|
||||
const result: Interface = {
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
|
||||
initial: {
|
||||
discover,
|
||||
source,
|
||||
},
|
||||
|
||||
provider: {
|
||||
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
||||
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)
|
||||
}),
|
||||
|
||||
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* () {
|
||||
const defaultModel = state.get().defaultModel
|
||||
if (defaultModel) {
|
||||
|
|
|
|||
|
|
@ -104,8 +104,9 @@ const layer = Layer.effectDiscard(
|
|||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const sdkPlugins = yield* SdkPlugins.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
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(Integration.Service, yield* Integration.Service),
|
||||
Context.make(AgentV2.Service, yield* AgentV2.Service),
|
||||
|
|
@ -139,37 +140,42 @@ const layer = Layer.effectDiscard(
|
|||
input.effect(context).pipe(Effect.provide(services)),
|
||||
)
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* add(ConfigReferencePlugin.Plugin)
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
yield* add(SkillPlugin.Plugin)
|
||||
yield* add(ModelsDevPlugin)
|
||||
yield* add(ConfigExternalPlugin.Plugin)
|
||||
yield* add(ApplyPatchTool.Plugin)
|
||||
yield* add(EditTool.Plugin)
|
||||
yield* add(GlobTool.Plugin)
|
||||
yield* add(GrepTool.Plugin)
|
||||
yield* add(QuestionTool.Plugin)
|
||||
yield* add(ReadTool.Plugin)
|
||||
yield* add(ShellTool.Plugin)
|
||||
yield* add(SkillTool.Plugin)
|
||||
yield* add(SubagentTool.Plugin)
|
||||
yield* add(TodoWriteTool.Plugin)
|
||||
yield* add(WebFetchTool.Plugin)
|
||||
yield* add(WebSearchTool.Plugin)
|
||||
yield* add(WriteTool.Plugin)
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(ConfigSkillPlugin.Plugin)
|
||||
for (const item of ProviderPlugins) yield* add(item)
|
||||
yield* add(ConfigProviderPlugin.Plugin)
|
||||
yield* add(VariantPlugin.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 }))
|
||||
yield* catalog.initial.discover(
|
||||
State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* add(ConfigReferencePlugin.Plugin)
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
yield* add(SkillPlugin.Plugin)
|
||||
yield* add(ModelsDevPlugin)
|
||||
yield* add(ConfigExternalPlugin.Plugin)
|
||||
yield* add(ApplyPatchTool.Plugin)
|
||||
yield* add(EditTool.Plugin)
|
||||
yield* add(GlobTool.Plugin)
|
||||
yield* add(GrepTool.Plugin)
|
||||
yield* add(QuestionTool.Plugin)
|
||||
yield* add(ReadTool.Plugin)
|
||||
yield* add(ShellTool.Plugin)
|
||||
yield* add(SkillTool.Plugin)
|
||||
yield* add(SubagentTool.Plugin)
|
||||
yield* add(TodoWriteTool.Plugin)
|
||||
yield* add(WebFetchTool.Plugin)
|
||||
yield* add(WebSearchTool.Plugin)
|
||||
yield* add(WriteTool.Plugin)
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(ConfigSkillPlugin.Plugin)
|
||||
for (const item of ProviderPlugins) yield* add(item)
|
||||
yield* add(ConfigProviderPlugin.Plugin)
|
||||
yield* add(VariantPlugin.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 }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { define } from "./internal"
|
||||
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Catalog } from "../catalog"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
|
|
@ -199,9 +200,10 @@ function applyModel(
|
|||
export const ModelsDevPlugin = define({
|
||||
id: "models-dev",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const catalog = yield* Catalog.Service
|
||||
const modelsDev = yield* ModelsDev.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) => {
|
||||
for (const item of Object.values(loaded.data)) {
|
||||
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(
|
||||
Stream.runForEach(() =>
|
||||
modelsDev.get().pipe(
|
||||
Effect.tap((data) => Effect.sync(() => (loaded.data = data))),
|
||||
Effect.andThen(ctx.integration.reload()),
|
||||
Effect.andThen(ctx.catalog.reload()),
|
||||
),
|
||||
),
|
||||
Stream.runForEach(() => refresh.pipe(Effect.ignoreCause)),
|
||||
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 type { Scope } from "effect"
|
||||
import { Credential } from "../../credential"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { EventV2 } from "../../event"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Integration } from "../../integration"
|
||||
|
|
@ -162,14 +163,13 @@ export const OpenAIPlugin = define({
|
|||
id: "openai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const events = yield* EventV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let chatgpt = false
|
||||
|
||||
const load = Effect.fn("OpenAIPlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("openai")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
|
||||
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(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
|
||||
Stream.runForEach(refresh),
|
||||
Stream.runForEach(() => refresh().pipe(Effect.ignoreCause)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh().pipe(Effect.forkScoped)
|
||||
yield* catalog.initial.source(refresh())
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
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 { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { EventV2 } from "../../event"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
|
|
@ -74,10 +75,11 @@ function oauth(http: HttpClient.HttpClient) {
|
|||
} 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",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const events = yield* EventV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
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)))
|
||||
: undefined
|
||||
connected = connection !== undefined
|
||||
providers = credential
|
||||
? yield* fetchProviders(http, credential).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
providers = credential ? yield* fetchProviders(http, credential) : undefined
|
||||
})
|
||||
|
||||
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(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
|
||||
Stream.runForEach(refresh),
|
||||
Stream.runForEach(() => refresh().pipe(Effect.ignoreCause)),
|
||||
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 =
|
||||
| Catalog.IncompleteError
|
||||
| ModelNotSelectedError
|
||||
| ModelUnavailableError
|
||||
| VariantUnavailableError
|
||||
|
|
@ -234,15 +235,7 @@ const layer = Layer.effect(
|
|||
const integrations = yield* Integration.Service
|
||||
return Service.of({
|
||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
||||
// Location plugins populate and filter the catalog asynchronously during layer startup.
|
||||
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)
|
||||
const selected = yield* catalog.model.select(session.model, supported)
|
||||
if (!selected && session.model)
|
||||
return yield* new ModelUnavailableError({
|
||||
providerID: session.model.providerID,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ export interface Options<State, DraftApi> {
|
|||
readonly draft: MakeDraft<State, DraftApi>
|
||||
/** Runs after all active transforms and before the rebuilt state becomes visible. */
|
||||
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> {
|
||||
|
|
@ -67,6 +69,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
|||
const api = options.draft(next)
|
||||
if (options.finalize) yield* options.finalize(api)
|
||||
state = next
|
||||
if (options.committed) yield* options.committed()
|
||||
})
|
||||
|
||||
const apply = (transform: TransformCallback<DraftApi>, draft: DraftApi) =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { Integration } from "@opencode-ai/core/integration"
|
||||
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 { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
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 { testEffect } from "./lib/effect"
|
||||
|
||||
|
|
@ -27,8 +30,341 @@ const catalogLayer = AppNodeBuilder.build(
|
|||
[[Location.node, locationLayer]],
|
||||
)
|
||||
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", () => {
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
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 { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
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 { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
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 { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
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])))
|
||||
|
||||
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", () =>
|
||||
Effect.acquireRelease(
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = State.create({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue