From 663e798e7624c345dd302fb316c67c7ce10551b0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 12:40:00 -0400 Subject: [PATCH 1/8] refactor(provider): remove async facade exports (#22320) --- packages/opencode/src/cli/cmd/debug/agent.ts | 9 +- packages/opencode/src/cli/cmd/models.ts | 72 ++++--- packages/opencode/src/provider/provider.ts | 31 --- .../opencode/src/server/instance/config.ts | 9 +- .../opencode/src/server/instance/provider.ts | 47 +++-- .../test/provider/amazon-bedrock.test.ts | 31 ++- .../opencode/test/provider/gitlab-duo.test.ts | 36 ++-- .../opencode/test/provider/provider.test.ts | 195 +++++++++++------- packages/opencode/test/session/llm.test.ts | 28 ++- .../test/session/prompt-effect.test.ts | 2 +- 10 files changed, 261 insertions(+), 199 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 25a32d23b3..fbaf8d78dc 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -125,7 +125,14 @@ function parseToolParams(input?: string) { async function createToolContext(agent: Agent.Info) { const session = await Session.create({ title: `Debug tool run (${agent.name})` }) const messageID = MessageID.ascending() - const model = agent.model ?? (await Provider.defaultModel()) + const model = + agent.model ?? + (await AppRuntime.runPromise( + Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* provider.defaultModel() + }), + )) const now = Date.now() const message: MessageV2.Assistant = { id: messageID, diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 4670aa5f2f..ad9300da2e 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -6,6 +6,8 @@ import { ModelsDev } from "../../provider/models" import { cmd } from "./cmd" import { UI } from "../ui" import { EOL } from "os" +import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" export const ModelsCommand = cmd({ command: "models [provider]", @@ -35,43 +37,51 @@ export const ModelsCommand = cmd({ await Instance.provide({ directory: process.cwd(), async fn() { - const providers = await Provider.list() + await AppRuntime.runPromise( + Effect.gen(function* () { + const svc = yield* Provider.Service + const providers = yield* svc.list() - function printModels(providerID: ProviderID, verbose?: boolean) { - const provider = providers[providerID] - const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b)) - for (const [modelID, model] of sortedModels) { - process.stdout.write(`${providerID}/${modelID}`) - process.stdout.write(EOL) - if (verbose) { - process.stdout.write(JSON.stringify(model, null, 2)) - process.stdout.write(EOL) + const print = (providerID: ProviderID, verbose?: boolean) => { + const provider = providers[providerID] + const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b)) + for (const [modelID, model] of sorted) { + process.stdout.write(`${providerID}/${modelID}`) + process.stdout.write(EOL) + if (verbose) { + process.stdout.write(JSON.stringify(model, null, 2)) + process.stdout.write(EOL) + } + } } - } - } - if (args.provider) { - const provider = providers[ProviderID.make(args.provider)] - if (!provider) { - UI.error(`Provider not found: ${args.provider}`) - return - } + if (args.provider) { + const providerID = ProviderID.make(args.provider) + const provider = providers[providerID] + if (!provider) { + yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`)) + return + } - printModels(ProviderID.make(args.provider), args.verbose) - return - } + yield* Effect.sync(() => print(providerID, args.verbose)) + return + } - const providerIDs = Object.keys(providers).sort((a, b) => { - const aIsOpencode = a.startsWith("opencode") - const bIsOpencode = b.startsWith("opencode") - if (aIsOpencode && !bIsOpencode) return -1 - if (!aIsOpencode && bIsOpencode) return 1 - return a.localeCompare(b) - }) + const ids = Object.keys(providers).sort((a, b) => { + const aIsOpencode = a.startsWith("opencode") + const bIsOpencode = b.startsWith("opencode") + if (aIsOpencode && !bIsOpencode) return -1 + if (!aIsOpencode && bIsOpencode) return 1 + return a.localeCompare(b) + }) - for (const providerID of providerIDs) { - printModels(ProviderID.make(providerID), args.verbose) - } + yield* Effect.sync(() => { + for (const providerID of ids) { + print(ProviderID.make(providerID), args.verbose) + } + }) + }), + ) }, }) }, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index a26b254d5a..bf27f090ab 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -21,7 +21,6 @@ import path from "path" import { Effect, Layer, Context } from "effect" import { EffectLogger } from "@/effect/logger" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@/filesystem" import { isRecord } from "@/util/record" @@ -1693,36 +1692,6 @@ export namespace Provider { ), ) - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function list() { - return runPromise((svc) => svc.list()) - } - - export async function getProvider(providerID: ProviderID) { - return runPromise((svc) => svc.getProvider(providerID)) - } - - export async function getModel(providerID: ProviderID, modelID: ModelID) { - return runPromise((svc) => svc.getModel(providerID, modelID)) - } - - export async function getLanguage(model: Model) { - return runPromise((svc) => svc.getLanguage(model)) - } - - export async function closest(providerID: ProviderID, query: string[]) { - return runPromise((svc) => svc.closest(providerID, query)) - } - - export async function getSmallModel(providerID: ProviderID) { - return runPromise((svc) => svc.getSmallModel(providerID)) - } - - export async function defaultModel() { - return runPromise((svc) => svc.defaultModel()) - } - const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] export function sort(models: T[]) { return sortBy( diff --git a/packages/opencode/src/server/instance/config.ts b/packages/opencode/src/server/instance/config.ts index 85d28f6aa6..2b28ba450d 100644 --- a/packages/opencode/src/server/instance/config.ts +++ b/packages/opencode/src/server/instance/config.ts @@ -7,6 +7,8 @@ import { mapValues } from "remeda" import { errors } from "../error" import { Log } from "../../util/log" import { lazy } from "../../util/lazy" +import { AppRuntime } from "../../effect/app-runtime" +import { Effect } from "effect" const log = Log.create({ service: "server" }) @@ -82,7 +84,12 @@ export const ConfigRoutes = lazy(() => }), async (c) => { using _ = log.time("providers") - const providers = await Provider.list().then((x) => mapValues(x, (item) => item)) + const providers = await AppRuntime.runPromise( + Effect.gen(function* () { + const svc = yield* Provider.Service + return mapValues(yield* svc.list(), (item) => item) + }), + ) return c.json({ providers: Object.values(providers), default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), diff --git a/packages/opencode/src/server/instance/provider.ts b/packages/opencode/src/server/instance/provider.ts index efd126ea0f..ca203d6a4b 100644 --- a/packages/opencode/src/server/instance/provider.ts +++ b/packages/opencode/src/server/instance/provider.ts @@ -11,6 +11,7 @@ import { mapValues } from "remeda" import { errors } from "../error" import { lazy } from "../../util/lazy" import { Log } from "../../util/log" +import { Effect } from "effect" const log = Log.create({ service: "server" }) @@ -40,27 +41,35 @@ export const ProviderRoutes = lazy(() => }, }), async (c) => { - const config = await Config.get() - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - - const allProviders = await ModelsDev.get() - const filteredProviders: Record = {} - for (const [key, value] of Object.entries(allProviders)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filteredProviders[key] = value - } - } - - const connected = await Provider.list() - const providers = Object.assign( - mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)), - connected, + const result = await AppRuntime.runPromise( + Effect.gen(function* () { + const svc = yield* Provider.Service + const config = yield* Effect.promise(() => Config.get()) + const all = yield* Effect.promise(() => ModelsDev.get()) + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + const filtered: Record = {} + for (const [key, value] of Object.entries(all)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { + filtered[key] = value + } + } + const connected = yield* svc.list() + const providers = Object.assign( + mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)), + connected, + ) + return { + all: Object.values(providers), + default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), + connected: Object.keys(connected), + } + }), ) return c.json({ - all: Object.values(providers), - default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), - connected: Object.keys(connected), + all: result.all, + default: result.default, + connected: result.connected, }) }, ) diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 3358e92300..712f36086f 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -9,6 +9,17 @@ import { Provider } from "../../src/provider/provider" import { Env } from "../../src/env" import { Global } from "../../src/global" import { Filesystem } from "../../src/util/filesystem" +import { Effect } from "effect" +import { AppRuntime } from "../../src/effect/app-runtime" + +async function list() { + return AppRuntime.runPromise( + Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* provider.list() + }), + ) +} test("Bedrock: config region takes precedence over AWS_REGION env var", async () => { await using tmp = await tmpdir({ @@ -35,7 +46,7 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async () Env.set("AWS_PROFILE", "default") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") }, @@ -60,7 +71,7 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async () Env.set("AWS_PROFILE", "default") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") }, @@ -116,7 +127,7 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { Env.set("AWS_BEARER_TOKEN_BEDROCK", "") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") }, @@ -161,7 +172,7 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async Env.set("AWS_ACCESS_KEY_ID", "test-key-id") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") }, @@ -192,7 +203,7 @@ test("Bedrock: includes custom endpoint in options when specified", async () => Env.set("AWS_PROFILE", "default") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe( "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com", @@ -228,7 +239,7 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () Env.set("AWS_ACCESS_KEY_ID", "") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") }, @@ -268,7 +279,7 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () => Env.set("AWS_PROFILE", "default") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() // The model should exist with the us. prefix expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() @@ -305,7 +316,7 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => { Env.set("AWS_PROFILE", "default") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() }, @@ -341,7 +352,7 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () => Env.set("AWS_PROFILE", "default") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() }, @@ -377,7 +388,7 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a Env.set("AWS_PROFILE", "default") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() // Non-prefixed model should still be registered expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index b669a1e21a..9b5441fe22 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -30,7 +30,7 @@ // Env.set("GITLAB_TOKEN", "test-gitlab-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab].key).toBe("test-gitlab-token") // }, @@ -62,7 +62,7 @@ // Env.set("GITLAB_INSTANCE_URL", "https://gitlab.example.com") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.example.com") // }, @@ -100,7 +100,7 @@ // Env.set("GITLAB_TOKEN", "") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // }, // }) @@ -135,7 +135,7 @@ // Env.set("GITLAB_TOKEN", "") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab].key).toBe("glpat-test-pat-token") // }, @@ -167,7 +167,7 @@ // Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.company.internal") // }, @@ -198,7 +198,7 @@ // Env.set("GITLAB_TOKEN", "env-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // }, // }) @@ -221,7 +221,7 @@ // Env.set("GITLAB_TOKEN", "test-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain( // "context-1m-2025-08-07", @@ -257,7 +257,7 @@ // Env.set("GITLAB_TOKEN", "test-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab].options?.featureFlags).toBeDefined() // expect(providers[ProviderID.gitlab].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true) @@ -282,7 +282,7 @@ // Env.set("GITLAB_TOKEN", "test-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() // const models = Object.keys(providers[ProviderID.gitlab].models) // expect(models.length).toBeGreaterThan(0) @@ -306,7 +306,7 @@ // Env.set("GITLAB_TOKEN", "test-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // const gitlab = providers[ProviderID.gitlab] // expect(gitlab).toBeDefined() // gitlab.models["duo-workflow-sonnet-4-6"] = { @@ -332,10 +332,10 @@ // release_date: "", // variants: {}, // } -// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6")) +// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6")) // expect(model).toBeDefined() // expect(model.options?.workflowRef).toBe("claude_sonnet_4_6") -// const language = await Provider.getLanguage(model) +// const language = await getLanguage(model) // expect(language).toBeDefined() // expect(language).toBeInstanceOf(GitLabWorkflowLanguageModel) // }, @@ -354,11 +354,11 @@ // Env.set("GITLAB_TOKEN", "test-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // expect(providers[ProviderID.gitlab]).toBeDefined() -// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5")) +// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5")) // expect(model).toBeDefined() -// const language = await Provider.getLanguage(model) +// const language = await getLanguage(model) // expect(language).toBeDefined() // expect(language).not.toBeInstanceOf(GitLabWorkflowLanguageModel) // }, @@ -377,10 +377,10 @@ // Env.set("GITLAB_TOKEN", "test-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // const gitlab = providers[ProviderID.gitlab] // expect(gitlab.options?.featureFlags).toBeDefined() -// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5")) +// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5")) // expect(model).toBeDefined() // expect(model.options).toBeDefined() // }, @@ -401,7 +401,7 @@ // Env.set("GITLAB_TOKEN", "test-token") // }, // fn: async () => { -// const providers = await Provider.list() +// const providers = await list() // const models = Object.keys(providers[ProviderID.gitlab].models) // expect(models).toContain("duo-chat-haiku-4-5") // expect(models).toContain("duo-chat-sonnet-4-5") diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 9cadc391a1..73e77be5fd 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -11,8 +11,47 @@ import { Provider } from "../../src/provider/provider" import { ProviderID, ModelID } from "../../src/provider/schema" import { Filesystem } from "../../src/util/filesystem" import { Env } from "../../src/env" +import { Effect } from "effect" +import { AppRuntime } from "../../src/effect/app-runtime" -function paid(providers: Awaited>) { +async function run(fn: (provider: Provider.Interface) => Effect.Effect) { + return AppRuntime.runPromise( + Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* fn(provider) + }), + ) +} + +async function list() { + return run((provider) => provider.list()) +} + +async function getProvider(providerID: ProviderID) { + return run((provider) => provider.getProvider(providerID)) +} + +async function getModel(providerID: ProviderID, modelID: ModelID) { + return run((provider) => provider.getModel(providerID, modelID)) +} + +async function getLanguage(model: Provider.Model) { + return run((provider) => provider.getLanguage(model)) +} + +async function closest(providerID: ProviderID, query: string[]) { + return run((provider) => provider.closest(providerID, query)) +} + +async function getSmallModel(providerID: ProviderID) { + return run((provider) => provider.getSmallModel(providerID)) +} + +async function defaultModel() { + return run((provider) => provider.defaultModel()) +} + +function paid(providers: Awaited>) { const item = providers[ProviderID.make("opencode")] expect(item).toBeDefined() return Object.values(item.models).filter((model) => model.cost.input > 0).length @@ -35,7 +74,7 @@ test("provider loaded from env variable", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() // Provider should retain its connection source even if custom loaders // merge additional options. @@ -66,7 +105,7 @@ test("provider loaded from config with apiKey option", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() }, }) @@ -90,7 +129,7 @@ test("disabled_providers excludes provider", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() }, }) @@ -115,7 +154,7 @@ test("enabled_providers restricts to only listed providers", async () => { Env.set("OPENAI_API_KEY", "test-openai-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() }, @@ -144,7 +183,7 @@ test("model whitelist filters models for provider", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) expect(models).toContain("claude-sonnet-4-20250514") @@ -175,7 +214,7 @@ test("model blacklist excludes specific models", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) expect(models).not.toContain("claude-sonnet-4-20250514") @@ -210,7 +249,7 @@ test("custom model alias via config", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined() expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias") @@ -253,7 +292,7 @@ test("custom provider with npm package", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("custom-provider")]).toBeDefined() expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider") expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined() @@ -286,7 +325,7 @@ test("env variable takes precedence, config merges options", async () => { Env.set("ANTHROPIC_API_KEY", "env-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() // Config options should be merged expect(providers[ProviderID.anthropic].options.timeout).toBe(60000) @@ -312,11 +351,11 @@ test("getModel returns model for valid provider/model", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const model = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) + const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model).toBeDefined() expect(String(model.providerID)).toBe("anthropic") expect(String(model.id)).toBe("claude-sonnet-4-20250514") - const language = await Provider.getLanguage(model) + const language = await getLanguage(model) expect(language).toBeDefined() }, }) @@ -339,7 +378,7 @@ test("getModel throws ModelNotFoundError for invalid model", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - expect(Provider.getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() + expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() }, }) }) @@ -358,7 +397,7 @@ test("getModel throws ModelNotFoundError for invalid provider", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - expect(Provider.getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow() + expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow() }, }) }) @@ -392,7 +431,7 @@ test("defaultModel returns first available model when no config set", async () = Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const model = await Provider.defaultModel() + const model = await defaultModel() expect(model.providerID).toBeDefined() expect(model.modelID).toBeDefined() }, @@ -417,7 +456,7 @@ test("defaultModel respects config model setting", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const model = await Provider.defaultModel() + const model = await defaultModel() expect(String(model.providerID)).toBe("anthropic") expect(String(model.modelID)).toBe("claude-sonnet-4-20250514") }, @@ -456,7 +495,7 @@ test("provider with baseURL from config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("custom-openai")]).toBeDefined() expect(providers[ProviderID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1") }, @@ -494,7 +533,7 @@ test("model cost defaults to zero when not specified", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.make("test-provider")].models["test-model"] expect(model.cost.input).toBe(0) expect(model.cost.output).toBe(0) @@ -532,7 +571,7 @@ test("model options are merged from existing model", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.options.customOption).toBe("custom-value") }, @@ -561,7 +600,7 @@ test("provider removed when all models filtered out", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() }, }) @@ -584,7 +623,7 @@ test("closest finds model by partial match", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const result = await Provider.closest(ProviderID.anthropic, ["sonnet-4"]) + const result = await closest(ProviderID.anthropic, ["sonnet-4"]) expect(result).toBeDefined() expect(String(result?.providerID)).toBe("anthropic") expect(String(result?.modelID)).toContain("sonnet-4") @@ -606,7 +645,7 @@ test("closest returns undefined for nonexistent provider", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await Provider.closest(ProviderID.make("nonexistent"), ["model"]) + const result = await closest(ProviderID.make("nonexistent"), ["model"]) expect(result).toBeUndefined() }, }) @@ -639,10 +678,10 @@ test("getModel uses realIdByKey for aliased models", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined() - const model = await Provider.getModel(ProviderID.anthropic, ModelID.make("my-sonnet")) + const model = await getModel(ProviderID.anthropic, ModelID.make("my-sonnet")) expect(model).toBeDefined() expect(String(model.id)).toBe("my-sonnet") expect(model.name).toBe("My Sonnet Alias") @@ -682,7 +721,7 @@ test("provider api field sets model api.url", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() // api field is stored on model.api.url, used by getSDK to set baseURL expect(providers[ProviderID.make("custom-api")].models["model-1"].api.url).toBe("https://api.example.com/v1") }, @@ -722,7 +761,7 @@ test("explicit baseURL overrides api field", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1") }, }) @@ -754,7 +793,7 @@ test("model inherits properties from existing database model", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.name).toBe("Custom Name for Sonnet") expect(model.capabilities.toolcall).toBe(true) @@ -782,7 +821,7 @@ test("disabled_providers prevents loading even with env var", async () => { Env.set("OPENAI_API_KEY", "test-openai-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.openai]).toBeUndefined() }, }) @@ -807,7 +846,7 @@ test("enabled_providers with empty array allows no providers", async () => { Env.set("OPENAI_API_KEY", "test-openai-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(Object.keys(providers).length).toBe(0) }, }) @@ -836,7 +875,7 @@ test("whitelist and blacklist can be combined", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) expect(models).toContain("claude-sonnet-4-20250514") @@ -875,7 +914,7 @@ test("model modalities default correctly", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.make("test-provider")].models["test-model"] expect(model.capabilities.input.text).toBe(true) expect(model.capabilities.output.text).toBe(true) @@ -918,7 +957,7 @@ test("model with custom cost values", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.make("test-provider")].models["test-model"] expect(model.cost.input).toBe(5) expect(model.cost.output).toBe(15) @@ -945,7 +984,7 @@ test("getSmallModel returns appropriate small model", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const model = await Provider.getSmallModel(ProviderID.anthropic) + const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() expect(model?.id).toContain("haiku") }, @@ -970,7 +1009,7 @@ test("getSmallModel respects config small_model override", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const model = await Provider.getSmallModel(ProviderID.anthropic) + const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() expect(String(model?.providerID)).toBe("anthropic") expect(String(model?.id)).toBe("claude-sonnet-4-20250514") @@ -1019,7 +1058,7 @@ test("multiple providers can be configured simultaneously", async () => { Env.set("OPENAI_API_KEY", "test-openai-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeDefined() expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) @@ -1060,7 +1099,7 @@ test("provider with custom npm package", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("local-llm")]).toBeDefined() expect(providers[ProviderID.make("local-llm")].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible") expect(providers[ProviderID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1") @@ -1097,7 +1136,7 @@ test("model alias name defaults to alias key when id differs", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet") }, }) @@ -1137,7 +1176,7 @@ test("provider with multiple env var options only includes apiKey when single en Env.set("MULTI_ENV_KEY_1", "test-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("multi-env")]).toBeDefined() // When multiple env options exist, key should NOT be auto-set expect(providers[ProviderID.make("multi-env")].key).toBeUndefined() @@ -1179,7 +1218,7 @@ test("provider with single env var includes apiKey automatically", async () => { Env.set("SINGLE_ENV_KEY", "my-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("single-env")]).toBeDefined() // Single env option should auto-set key expect(providers[ProviderID.make("single-env")].key).toBe("my-api-key") @@ -1216,7 +1255,7 @@ test("model cost overrides existing cost values", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.cost.input).toBe(999) expect(model.cost.output).toBe(888) @@ -1263,7 +1302,7 @@ test("completely new provider not in database can be configured", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("brand-new-provider")]).toBeDefined() expect(providers[ProviderID.make("brand-new-provider")].name).toBe("Brand New") const model = providers[ProviderID.make("brand-new-provider")].models["new-model"] @@ -1297,7 +1336,7 @@ test("disabled_providers and enabled_providers interaction", async () => { Env.set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() // anthropic: in enabled, not in disabled = allowed expect(providers[ProviderID.anthropic]).toBeDefined() // openai: in enabled, but also in disabled = NOT allowed @@ -1337,7 +1376,7 @@ test("model with tool_call false", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false) }, }) @@ -1372,7 +1411,7 @@ test("model defaults tool_call to true when not specified", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true) }, }) @@ -1411,7 +1450,7 @@ test("model headers are preserved", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.make("headers-provider")].models["model"] expect(model.headers).toEqual({ "X-Custom-Header": "custom-value", @@ -1454,7 +1493,7 @@ test("provider env fallback - second env var used if first missing", async () => Env.set("FALLBACK_KEY", "fallback-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() // Provider should load because fallback env var is set expect(providers[ProviderID.make("fallback-env")]).toBeDefined() }, @@ -1478,8 +1517,8 @@ test("getModel returns consistent results", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const model1 = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) - const model2 = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) + const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) + const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model1.providerID).toEqual(model2.providerID) expect(model1.id).toEqual(model2.id) expect(model1).toEqual(model2) @@ -1516,7 +1555,7 @@ test("provider name defaults to id when not in database", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("my-custom-id")].name).toBe("my-custom-id") }, }) @@ -1540,7 +1579,7 @@ test("ModelNotFoundError includes suggestions for typos", async () => { }, fn: async () => { try { - await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet + await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet expect(true).toBe(false) // Should not reach here } catch (e: any) { expect(e.data.suggestions).toBeDefined() @@ -1568,7 +1607,7 @@ test("ModelNotFoundError for provider includes suggestions", async () => { }, fn: async () => { try { - await Provider.getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic + await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic expect(true).toBe(false) // Should not reach here } catch (e: any) { expect(e.data.suggestions).toBeDefined() @@ -1592,7 +1631,7 @@ test("getProvider returns undefined for nonexistent provider", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const provider = await Provider.getProvider(ProviderID.make("nonexistent")) + const provider = await getProvider(ProviderID.make("nonexistent")) expect(provider).toBeUndefined() }, }) @@ -1615,7 +1654,7 @@ test("getProvider returns provider info", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const provider = await Provider.getProvider(ProviderID.anthropic) + const provider = await getProvider(ProviderID.anthropic) expect(provider).toBeDefined() expect(String(provider?.id)).toBe("anthropic") }, @@ -1639,7 +1678,7 @@ test("closest returns undefined when no partial match found", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const result = await Provider.closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) + const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) expect(result).toBeUndefined() }, }) @@ -1663,7 +1702,7 @@ test("closest checks multiple query terms in order", async () => { }, fn: async () => { // First term won't match, second will - const result = await Provider.closest(ProviderID.anthropic, ["nonexistent", "haiku"]) + const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"]) expect(result).toBeDefined() expect(result?.modelID).toContain("haiku") }, @@ -1699,7 +1738,7 @@ test("model limit defaults to zero when not specified", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.make("no-limit")].models["model"] expect(model.limit.context).toBe(0) expect(model.limit.output).toBe(0) @@ -1734,7 +1773,7 @@ test("provider options are deeply merged", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() // Custom options should be merged expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) expect(providers[ProviderID.anthropic].options.headers["X-Custom"]).toBe("custom-value") @@ -1772,7 +1811,7 @@ test("custom model inherits npm package from models.dev provider config", async Env.set("OPENAI_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.openai].models["my-custom-model"] expect(model).toBeDefined() expect(model.api.npm).toBe("@ai-sdk/openai") @@ -1807,7 +1846,7 @@ test("custom model inherits api.url from models.dev provider", async () => { Env.set("OPENROUTER_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.openrouter]).toBeDefined() // New model not in database should inherit api.url from provider @@ -1908,7 +1947,7 @@ test("model variants are generated for reasoning models", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() // Claude sonnet 4 has reasoning capability const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.capabilities.reasoning).toBe(true) @@ -1946,7 +1985,7 @@ test("model variants can be disabled via config", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() expect(model.variants!["high"]).toBeUndefined() @@ -1989,7 +2028,7 @@ test("model variants can be customized via config", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() expect(model.variants!["high"].thinking.budgetTokens).toBe(20000) @@ -2028,7 +2067,7 @@ test("disabled key is stripped from variant config", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["max"]).toBeDefined() expect(model.variants!["max"].disabled).toBeUndefined() @@ -2066,7 +2105,7 @@ test("all variants can be disabled via config", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() expect(Object.keys(model.variants!).length).toBe(0) @@ -2104,7 +2143,7 @@ test("variant config merges with generated variants", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() // Should have both the generated thinking config and the custom option @@ -2142,7 +2181,7 @@ test("variants filtered in second pass for database models", async () => { Env.set("OPENAI_API_KEY", "test-api-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.openai].models["gpt-5"] expect(model.variants).toBeDefined() expect(model.variants!["high"]).toBeUndefined() @@ -2188,7 +2227,7 @@ test("custom model with variants enabled and disabled", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.make("custom-reasoning")].models["reasoning-model"] expect(model.variants).toBeDefined() // Enabled variants should exist @@ -2246,7 +2285,7 @@ test("Google Vertex: retains baseURL for custom proxy", async () => { Env.set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined() expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1") }, @@ -2291,7 +2330,7 @@ test("Google Vertex: supports OpenAI compatible models", async () => { Env.set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"] expect(model).toBeDefined() @@ -2319,7 +2358,7 @@ test("cloudflare-ai-gateway loads with env variables", async () => { Env.set("CLOUDFLARE_API_TOKEN", "test-token") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() }, }) @@ -2351,7 +2390,7 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { Env.set("CLOUDFLARE_API_TOKEN", "test-token") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({ invoked_by: "test", @@ -2399,7 +2438,7 @@ test("plugin config providers persist after instance dispose", async () => { directory: tmp.path, fn: async () => { await Plugin.init() - return Provider.list() + return list() }, }) expect(first[ProviderID.make("demo")]).toBeDefined() @@ -2409,7 +2448,7 @@ test("plugin config providers persist after instance dispose", async () => { const second = await Instance.provide({ directory: tmp.path, - fn: async () => Provider.list(), + fn: async () => list(), }) expect(second[ProviderID.make("demo")]).toBeDefined() expect(second[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined() @@ -2445,7 +2484,7 @@ test("plugin config enabled and disabled providers are honored", async () => { Env.set("OPENAI_API_KEY", "test-openai-key") }, fn: async () => { - const providers = await Provider.list() + const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() }, @@ -2466,7 +2505,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () const none = await Instance.provide({ directory: base.path, - fn: async () => paid(await Provider.list()), + fn: async () => paid(await list()), }) await using keyed = await tmpdir({ @@ -2489,7 +2528,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () const keyedCount = await Instance.provide({ directory: keyed.path, - fn: async () => paid(await Provider.list()), + fn: async () => paid(await list()), }) expect(none).toBe(0) @@ -2510,7 +2549,7 @@ test("opencode loader keeps paid models when auth exists", async () => { const none = await Instance.provide({ directory: base.path, - fn: async () => paid(await Provider.list()), + fn: async () => paid(await list()), }) await using keyed = await tmpdir({ @@ -2544,7 +2583,7 @@ test("opencode loader keeps paid models when auth exists", async () => { const keyedCount = await Instance.provide({ directory: keyed.path, - fn: async () => paid(await Provider.list()), + fn: async () => paid(await list()), }) expect(none).toBe(0) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 1fa2e61eb2..3974ca9810 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import path from "path" import { tool, type ModelMessage } from "ai" -import { Cause, Exit, Stream } from "effect" +import { Cause, Effect, Exit, Stream } from "effect" import z from "zod" import { makeRuntime } from "../../src/effect/run-service" import { LLM } from "../../src/session/llm" @@ -15,6 +15,16 @@ import { tmpdir } from "../fixture/fixture" import type { Agent } from "../../src/agent/agent" import type { MessageV2 } from "../../src/session/message-v2" import { SessionID, MessageID } from "../../src/session/schema" +import { AppRuntime } from "../../src/effect/app-runtime" + +async function getModel(providerID: ProviderID, modelID: ModelID) { + return AppRuntime.runPromise( + Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* provider.getModel(providerID, modelID) + }), + ) +} describe("session.llm.hasToolCalls", () => { test("returns false for empty messages array", () => { @@ -325,7 +335,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const sessionID = SessionID.make("session-test-1") const agent = { name: "test", @@ -416,7 +426,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const sessionID = SessionID.make("session-test-raw-abort") const agent = { name: "test", @@ -490,7 +500,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const sessionID = SessionID.make("session-test-service-abort") const agent = { name: "test", @@ -581,7 +591,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const sessionID = SessionID.make("session-test-tools") const agent = { name: "test", @@ -699,7 +709,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id)) + const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) const sessionID = SessionID.make("session-test-2") const agent = { name: "test", @@ -819,7 +829,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id)) + const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) const sessionID = SessionID.make("session-test-data-url") const agent = { name: "test", @@ -942,7 +952,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const sessionID = SessionID.make("session-test-3") const agent = { name: "test", @@ -1043,7 +1053,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const sessionID = SessionID.make("session-test-4") const agent = { name: "test", diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 911c9f3443..eafe682067 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -204,7 +204,7 @@ const it = testEffect(makeHttp()) const unix = process.platform !== "win32" ? it.live : it.live.skip // Config that registers a custom "test" provider with a "test-model" model -// so Provider.getModel("test", "test-model") succeeds inside the loop. +// so provider model lookup succeeds inside the loop. const cfg = { provider: { test: { From 21d7a85e76983026bdd131b045f197386ef4c7ab Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 12:47:52 -0400 Subject: [PATCH 2/8] refactor(lsp): remove async facade exports (#22321) --- packages/opencode/src/cli/cmd/debug/lsp.ts | 19 +- packages/opencode/src/lsp/index.ts | 32 --- packages/opencode/src/server/instance/file.ts | 5 - .../opencode/src/server/instance/index.ts | 3 +- packages/opencode/test/lsp/index.test.ts | 94 ++++----- packages/opencode/test/lsp/lifecycle.test.ts | 187 +++++++++--------- 6 files changed, 159 insertions(+), 181 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index 4b8a3e7d45..5f0a1807d8 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -1,4 +1,6 @@ import { LSP } from "../../../lsp" +import { AppRuntime } from "../../../effect/app-runtime" +import { Effect } from "effect" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" import { Log } from "../../../util/log" @@ -19,9 +21,16 @@ const DiagnosticsCommand = cmd({ builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }), async handler(args) { await bootstrap(process.cwd(), async () => { - await LSP.touchFile(args.file, true) - await sleep(1000) - process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL) + const out = await AppRuntime.runPromise( + LSP.Service.use((lsp) => + Effect.gen(function* () { + yield* lsp.touchFile(args.file, true) + yield* Effect.sleep(1000) + return yield* lsp.diagnostics() + }), + ), + ) + process.stdout.write(JSON.stringify(out, null, 2) + EOL) }) }, }) @@ -33,7 +42,7 @@ export const SymbolsCommand = cmd({ async handler(args) { await bootstrap(process.cwd(), async () => { using _ = Log.Default.time("symbols") - const results = await LSP.workspaceSymbol(args.query) + const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))) process.stdout.write(JSON.stringify(results, null, 2) + EOL) }) }, @@ -46,7 +55,7 @@ export const DocumentSymbolsCommand = cmd({ async handler(args) { await bootstrap(process.cwd(), async () => { using _ = Log.Default.time("document-symbols") - const results = await LSP.documentSymbol(args.uri) + const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))) process.stdout.write(JSON.stringify(results, null, 2) + EOL) }) }, diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 8e34a88546..0c83890e55 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -13,7 +13,6 @@ import { Process } from "../util/process" import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context } from "effect" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" export namespace LSP { const log = Log.create({ service: "lsp" }) @@ -508,37 +507,6 @@ export namespace LSP { export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) - const { runPromise } = makeRuntime(Service, defaultLayer) - - export const init = async () => runPromise((svc) => svc.init()) - - export const status = async () => runPromise((svc) => svc.status()) - - export const hasClients = async (file: string) => runPromise((svc) => svc.hasClients(file)) - - export const touchFile = async (input: string, waitForDiagnostics?: boolean) => - runPromise((svc) => svc.touchFile(input, waitForDiagnostics)) - - export const diagnostics = async () => runPromise((svc) => svc.diagnostics()) - - export const hover = async (input: LocInput) => runPromise((svc) => svc.hover(input)) - - export const definition = async (input: LocInput) => runPromise((svc) => svc.definition(input)) - - export const references = async (input: LocInput) => runPromise((svc) => svc.references(input)) - - export const implementation = async (input: LocInput) => runPromise((svc) => svc.implementation(input)) - - export const documentSymbol = async (uri: string) => runPromise((svc) => svc.documentSymbol(uri)) - - export const workspaceSymbol = async (query: string) => runPromise((svc) => svc.workspaceSymbol(query)) - - export const prepareCallHierarchy = async (input: LocInput) => runPromise((svc) => svc.prepareCallHierarchy(input)) - - export const incomingCalls = async (input: LocInput) => runPromise((svc) => svc.incomingCalls(input)) - - export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input)) - export namespace Diagnostic { const MAX_PER_FILE = 20 diff --git a/packages/opencode/src/server/instance/file.ts b/packages/opencode/src/server/instance/file.ts index 713513b38d..a869cf3673 100644 --- a/packages/opencode/src/server/instance/file.ts +++ b/packages/opencode/src/server/instance/file.ts @@ -105,11 +105,6 @@ export const FileRoutes = lazy(() => }), ), async (c) => { - /* - const query = c.req.valid("query").query - const result = await LSP.workspaceSymbol(query) - return c.json(result) - */ return c.json([]) }, ) diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index 6009130a2f..6d383afa7c 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -256,7 +256,8 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => }, }), async (c) => { - return c.json(await LSP.status()) + const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status())) + return c.json(items) }, ) .get( diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index 7e514e39b1..b12a61ae3c 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -1,55 +1,55 @@ -import { describe, expect, spyOn, test } from "bun:test" +import { describe, expect, spyOn } from "bun:test" import path from "path" -import * as Lsp from "../../src/lsp/index" +import { Effect, Layer } from "effect" +import { LSP } from "../../src/lsp" import { LSPServer } from "../../src/lsp/server" -import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer)) describe("lsp.spawn", () => { - test("does not spawn builtin LSP for files outside instance", async () => { - await using tmp = await tmpdir() - const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) + it.live("does not spawn builtin LSP for files outside instance", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await Lsp.LSP.touchFile(path.join(tmp.path, "..", "outside.ts")) - await Lsp.LSP.hover({ - file: path.join(tmp.path, "..", "hover.ts"), - line: 0, - character: 0, - }) - }, - }) + try { + yield* lsp.touchFile(path.join(dir, "..", "outside.ts")) + yield* lsp.hover({ + file: path.join(dir, "..", "hover.ts"), + line: 0, + character: 0, + }) + expect(spy).toHaveBeenCalledTimes(0) + } finally { + spy.mockRestore() + } + }), + ), + ), + ) - expect(spy).toHaveBeenCalledTimes(0) - } finally { - spy.mockRestore() - await Instance.disposeAll() - } - }) + it.live("would spawn builtin LSP for files inside instance", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) - test("would spawn builtin LSP for files inside instance", async () => { - await using tmp = await tmpdir() - const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) - - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await Lsp.LSP.hover({ - file: path.join(tmp.path, "src", "inside.ts"), - line: 0, - character: 0, - }) - }, - }) - - expect(spy).toHaveBeenCalledTimes(1) - } finally { - spy.mockRestore() - await Instance.disposeAll() - } - }) + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.ts"), + line: 0, + character: 0, + }) + expect(spy).toHaveBeenCalledTimes(1) + } finally { + spy.mockRestore() + } + }), + ), + ), + ) }) diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts index fb3ed8c21c..a6de869fcb 100644 --- a/packages/opencode/test/lsp/lifecycle.test.ts +++ b/packages/opencode/test/lsp/lifecycle.test.ts @@ -1,23 +1,13 @@ -import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test" +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import path from "path" -import * as Lsp from "../../src/lsp/index" +import { Effect, Layer } from "effect" +import { LSP } from "../../src/lsp" import { LSPServer } from "../../src/lsp/server" -import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -function withInstance(fn: (dir: string) => Promise) { - return async () => { - await using tmp = await tmpdir() - try { - await Instance.provide({ - directory: tmp.path, - fn: () => fn(tmp.path), - }) - } finally { - await Instance.disposeAll() - } - } -} +const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer)) describe("LSP service lifecycle", () => { let spawnSpy: ReturnType @@ -30,97 +20,112 @@ describe("LSP service lifecycle", () => { spawnSpy.mockRestore() }) - test( - "init() completes without error", - withInstance(async () => { - await Lsp.LSP.init() - }), + it.live("init() completes without error", () => provideTmpdirInstance(() => LSP.Service.use((lsp) => lsp.init()))) + + it.live("status() returns empty array initially", () => + provideTmpdirInstance(() => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.status() + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(0) + }), + ), + ), ) - test( - "status() returns empty array initially", - withInstance(async () => { - const result = await Lsp.LSP.status() - expect(Array.isArray(result)).toBe(true) - expect(result.length).toBe(0) - }), + it.live("diagnostics() returns empty object initially", () => + provideTmpdirInstance(() => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.diagnostics() + expect(typeof result).toBe("object") + expect(Object.keys(result).length).toBe(0) + }), + ), + ), ) - test( - "diagnostics() returns empty object initially", - withInstance(async () => { - const result = await Lsp.LSP.diagnostics() - expect(typeof result).toBe("object") - expect(Object.keys(result).length).toBe(0) - }), + it.live("hasClients() returns true for .ts files in instance", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join(dir, "test.ts")) + expect(result).toBe(true) + }), + ), + ), ) - test( - "hasClients() returns true for .ts files in instance", - withInstance(async (dir) => { - const result = await Lsp.LSP.hasClients(path.join(dir, "test.ts")) - expect(result).toBe(true) - }), + it.live("hasClients() returns false for files outside instance", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join(dir, "..", "outside.ts")) + expect(typeof result).toBe("boolean") + }), + ), + ), ) - test( - "hasClients() returns false for files outside instance", - withInstance(async (dir) => { - const result = await Lsp.LSP.hasClients(path.join(dir, "..", "outside.ts")) - // hasClients checks servers but doesn't check containsPath — getClients does - // So hasClients may return true even for outside files (it checks extension + root) - // The guard is in getClients, not hasClients - expect(typeof result).toBe("boolean") - }), + it.live("workspaceSymbol() returns empty array with no clients", () => + provideTmpdirInstance(() => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.workspaceSymbol("test") + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(0) + }), + ), + ), ) - test( - "workspaceSymbol() returns empty array with no clients", - withInstance(async () => { - const result = await Lsp.LSP.workspaceSymbol("test") - expect(Array.isArray(result)).toBe(true) - expect(result.length).toBe(0) - }), + it.live("definition() returns empty array for unknown file", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.definition({ + file: path.join(dir, "nonexistent.ts"), + line: 0, + character: 0, + }) + expect(Array.isArray(result)).toBe(true) + }), + ), + ), ) - test( - "definition() returns empty array for unknown file", - withInstance(async (dir) => { - const result = await Lsp.LSP.definition({ - file: path.join(dir, "nonexistent.ts"), - line: 0, - character: 0, - }) - expect(Array.isArray(result)).toBe(true) - }), + it.live("references() returns empty array for unknown file", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.references({ + file: path.join(dir, "nonexistent.ts"), + line: 0, + character: 0, + }) + expect(Array.isArray(result)).toBe(true) + }), + ), + ), ) - test( - "references() returns empty array for unknown file", - withInstance(async (dir) => { - const result = await Lsp.LSP.references({ - file: path.join(dir, "nonexistent.ts"), - line: 0, - character: 0, - }) - expect(Array.isArray(result)).toBe(true) - }), - ) - - test( - "multiple init() calls are idempotent", - withInstance(async () => { - await Lsp.LSP.init() - await Lsp.LSP.init() - await Lsp.LSP.init() - // Should not throw or create duplicate state - }), + it.live("multiple init() calls are idempotent", () => + provideTmpdirInstance(() => + LSP.Service.use((lsp) => + Effect.gen(function* () { + yield* lsp.init() + yield* lsp.init() + yield* lsp.init() + }), + ), + ), ) }) describe("LSP.Diagnostic", () => { test("pretty() formats error diagnostic", () => { - const result = Lsp.LSP.Diagnostic.pretty({ + const result = LSP.Diagnostic.pretty({ range: { start: { line: 9, character: 4 }, end: { line: 9, character: 10 } }, message: "Type 'string' is not assignable to type 'number'", severity: 1, @@ -129,7 +134,7 @@ describe("LSP.Diagnostic", () => { }) test("pretty() formats warning diagnostic", () => { - const result = Lsp.LSP.Diagnostic.pretty({ + const result = LSP.Diagnostic.pretty({ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, message: "Unused variable", severity: 2, @@ -138,7 +143,7 @@ describe("LSP.Diagnostic", () => { }) test("pretty() defaults to ERROR when no severity", () => { - const result = Lsp.LSP.Diagnostic.pretty({ + const result = LSP.Diagnostic.pretty({ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, message: "Something wrong", } as any) From 91fe4db27c3ca0f9de8b06e9e508de6b6365956b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:06:03 +0000 Subject: [PATCH 3/8] Update VOUCHED list https://github.com/anomalyco/opencode/issues/22239#issuecomment-4238224546 --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 733748462b..1172850777 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -25,6 +25,7 @@ kommander -opencodeengineer bot that spams issues r44vc0rp rekram1-node +-ricardo-m-l -robinmordasiewicz simonklee -spider-yamet clawdbot/llm psychosis, spam pinging the team From f7c6943817e91189c44e1dab9271501c02c9e36f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 13:11:05 -0400 Subject: [PATCH 4/8] refactor(config): remove async facade exports (#22325) --- packages/opencode/script/seed-e2e.ts | 2 +- packages/opencode/src/cli/cmd/debug/config.ts | 3 +- packages/opencode/src/cli/cmd/mcp.ts | 9 +- packages/opencode/src/cli/cmd/providers.ts | 2 +- packages/opencode/src/cli/cmd/tui/worker.ts | 2 +- packages/opencode/src/cli/network.ts | 3 +- packages/opencode/src/cli/upgrade.ts | 2 +- packages/opencode/src/config/config.ts | 39 ----- packages/opencode/src/config/tui.ts | 3 +- .../opencode/src/server/instance/config.ts | 4 +- .../opencode/src/server/instance/global.ts | 4 +- .../opencode/src/server/instance/provider.ts | 3 +- .../opencode/test/config/agent-color.test.ts | 5 +- packages/opencode/test/config/config.test.ts | 144 ++++++++++-------- packages/opencode/test/config/tui.test.ts | 9 +- .../opencode/test/permission-task.test.ts | 15 +- 16 files changed, 117 insertions(+), 132 deletions(-) diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts index fe83b8ec08..7010f2d96a 100644 --- a/packages/opencode/script/seed-e2e.ts +++ b/packages/opencode/script/seed-e2e.ts @@ -25,7 +25,7 @@ const seed = async () => { directory: dir, init: () => AppRuntime.runPromise(InstanceBootstrap), fn: async () => { - await Config.waitForDependencies() + await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.waitForDependencies())) await AppRuntime.runPromise( Effect.gen(function* () { const registry = yield* ToolRegistry.Service diff --git a/packages/opencode/src/cli/cmd/debug/config.ts b/packages/opencode/src/cli/cmd/debug/config.ts index af536f93ce..59e29c4a38 100644 --- a/packages/opencode/src/cli/cmd/debug/config.ts +++ b/packages/opencode/src/cli/cmd/debug/config.ts @@ -1,5 +1,6 @@ import { EOL } from "os" import { Config } from "../../../config/config" +import { AppRuntime } from "@/effect/app-runtime" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" @@ -9,7 +10,7 @@ export const ConfigCommand = cmd({ builder: (yargs) => yargs, async handler() { await bootstrap(process.cwd(), async () => { - const config = await Config.get() + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) process.stdout.write(JSON.stringify(config, null, 2) + EOL) }) }, diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 41e498102e..e33ab6a35c 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -15,6 +15,7 @@ import { Global } from "../../global" import { modify, applyEdits } from "jsonc-parser" import { Filesystem } from "../../util/filesystem" import { Bus } from "../../bus" +import { AppRuntime } from "@/effect/app-runtime" function getAuthStatusIcon(status: MCP.AuthStatus): string { switch (status) { @@ -75,7 +76,7 @@ export const McpListCommand = cmd({ UI.empty() prompts.intro("MCP Servers") - const config = await Config.get() + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const mcpServers = config.mcp ?? {} const statuses = await MCP.status() @@ -152,7 +153,7 @@ export const McpAuthCommand = cmd({ UI.empty() prompts.intro("MCP OAuth Authentication") - const config = await Config.get() + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const mcpServers = config.mcp ?? {} // Get OAuth-capable servers (remote servers with oauth not explicitly disabled) @@ -289,7 +290,7 @@ export const McpAuthListCommand = cmd({ UI.empty() prompts.intro("MCP OAuth Status") - const config = await Config.get() + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const mcpServers = config.mcp ?? {} // Get OAuth-capable servers @@ -595,7 +596,7 @@ export const McpDebugCommand = cmd({ UI.empty() prompts.intro("MCP OAuth Debug") - const config = await Config.get() + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const mcpServers = config.mcp ?? {} const serverName = args.name diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 829e4e1b42..8d6159c9a5 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -326,7 +326,7 @@ export const ProvidersLoginCommand = cmd({ } await ModelsDev.refresh(true).catch(() => {}) - const config = await Config.get() + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 5e9bc62c10..4e1bdabcdd 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -81,7 +81,7 @@ export const rpc = { }) }, async reload() { - await Config.invalidate(true) + await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true))) }, async shutdown() { Log.Default.info("worker shutting down") diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index 84268e2675..cea49affa5 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -1,5 +1,6 @@ import type { Argv, InferredOptionTypes } from "yargs" import { Config } from "../config/config" +import { AppRuntime } from "@/effect/app-runtime" const options = { port: { @@ -37,7 +38,7 @@ export function withNetworkOptions(yargs: Argv) { } export async function resolveNetworkOptions(args: NetworkOptions) { - const config = await Config.getGlobal() + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) const portExplicitlySet = process.argv.includes("--port") const hostnameExplicitlySet = process.argv.includes("--hostname") const mdnsExplicitlySet = process.argv.includes("--mdns") diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index e902dcb921..f67b662455 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -5,7 +5,7 @@ import { Flag } from "@/flag/flag" import { Installation } from "@/installation" export async function upgrade() { - const config = await Config.getGlobal() + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method())) const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {}) if (!latest) return diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ab3abaf94e..6aa79e3090 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -33,7 +33,6 @@ import { ConfigPaths } from "./paths" import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@/filesystem" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" import { Flock } from "@/util/flock" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" @@ -1661,42 +1660,4 @@ export namespace Config { Layer.provide(Auth.defaultLayer), Layer.provide(Account.defaultLayer), ) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function get() { - return runPromise((svc) => svc.get()) - } - - export async function getGlobal() { - return runPromise((svc) => svc.getGlobal()) - } - - export async function getConsoleState() { - return runPromise((svc) => svc.getConsoleState()) - } - - export async function installDependencies(dir: string, input?: InstallInput) { - return runPromise((svc) => svc.installDependencies(dir, input)) - } - - export async function update(config: Info) { - return runPromise((svc) => svc.update(config)) - } - - export async function updateGlobal(config: Info) { - return runPromise((svc) => svc.updateGlobal(config)) - } - - export async function invalidate(wait = false) { - return runPromise((svc) => svc.invalidate(wait)) - } - - export async function directories() { - return runPromise((svc) => svc.directories()) - } - - export async function waitForDependencies() { - return runPromise((svc) => svc.waitForDependencies()) - } } diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index fa2022482d..ed9bb5b8d7 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -10,6 +10,7 @@ import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { isRecord } from "@/util/record" import { Global } from "@/global" +import { AppRuntime } from "@/effect/app-runtime" export namespace TuiConfig { const log = Log.create({ service: "tui.config" }) @@ -51,7 +52,7 @@ export namespace TuiConfig { } function installDeps(dir: string): Promise { - return Config.installDependencies(dir) + return AppRuntime.runPromise(Config.Service.use((cfg) => cfg.installDependencies(dir))) } async function mergeFile(acc: Acc, file: string) { diff --git a/packages/opencode/src/server/instance/config.ts b/packages/opencode/src/server/instance/config.ts index 2b28ba450d..41d5872c98 100644 --- a/packages/opencode/src/server/instance/config.ts +++ b/packages/opencode/src/server/instance/config.ts @@ -32,7 +32,7 @@ export const ConfigRoutes = lazy(() => }, }), async (c) => { - return c.json(await Config.get()) + return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))) }, ) .patch( @@ -56,7 +56,7 @@ export const ConfigRoutes = lazy(() => validator("json", Config.Info), async (c) => { const config = c.req.valid("json") - await Config.update(config) + await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config))) return c.json(config) }, ) diff --git a/packages/opencode/src/server/instance/global.ts b/packages/opencode/src/server/instance/global.ts index 6b0a9a164b..daecb6bd36 100644 --- a/packages/opencode/src/server/instance/global.ts +++ b/packages/opencode/src/server/instance/global.ts @@ -199,7 +199,7 @@ export const GlobalRoutes = lazy(() => }, }), async (c) => { - return c.json(await Config.getGlobal()) + return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))) }, ) .patch( @@ -223,7 +223,7 @@ export const GlobalRoutes = lazy(() => validator("json", Config.Info), async (c) => { const config = c.req.valid("json") - const next = await Config.updateGlobal(config) + const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config))) return c.json(next) }, ) diff --git a/packages/opencode/src/server/instance/provider.ts b/packages/opencode/src/server/instance/provider.ts index ca203d6a4b..6988d56e4e 100644 --- a/packages/opencode/src/server/instance/provider.ts +++ b/packages/opencode/src/server/instance/provider.ts @@ -44,7 +44,8 @@ export const ProviderRoutes = lazy(() => const result = await AppRuntime.runPromise( Effect.gen(function* () { const svc = yield* Provider.Service - const config = yield* Effect.promise(() => Config.get()) + const cfg = yield* Config.Service + const config = yield* cfg.get() const all = yield* Effect.promise(() => ModelsDev.get()) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index b9c7cccc48..0dc2653c2d 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -5,6 +5,9 @@ import { Instance } from "../../src/project/instance" import { Config } from "../../src/config/config" import { Agent as AgentSvc } from "../../src/agent/agent" import { Color } from "../../src/util/color" +import { AppRuntime } from "../../src/effect/app-runtime" + +const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) test("agent color parsed from project config", async () => { await using tmp = await tmpdir({ @@ -24,7 +27,7 @@ test("agent color parsed from project config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const cfg = await Config.get() + const cfg = await load() expect(cfg.agent?.["build"]?.color).toBe("#FFA500") expect(cfg.agent?.["plan"]?.color).toBe("primary") }, diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index d6931975c1..ce3566a0c5 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -33,15 +33,25 @@ const emptyAuth = Layer.mock(Auth.Service)({ all: () => Effect.succeed({}), }) -const it = testEffect( - Config.layer.pipe( - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(emptyAuth), - Layer.provide(emptyAccount), - Layer.provideMerge(infra), - ), +const layer = Config.layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(emptyAuth), + Layer.provide(emptyAccount), + Layer.provideMerge(infra), ) +const it = testEffect(layer) + +const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer))) +const save = (config: Config.Info) => + Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer))) +const clear = (wait = false) => + Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer))) +const listDirs = () => + Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer))) +const ready = () => + Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer))) + const installDeps = (dir: string, input?: Config.InstallInput) => Config.Service.use((svc) => svc.installDependencies(dir, input)) @@ -49,12 +59,12 @@ const installDeps = (dir: string, input?: Config.InstallInput) => const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! beforeEach(async () => { - await Config.invalidate(true) + await clear(true) }) afterEach(async () => { await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) - await Config.invalidate(true) + await clear(true) }) async function writeManagedSettings(settings: object, filename = "opencode.json") { @@ -72,7 +82,7 @@ async function check(map: (dir: string) => string) { await using tmp = await tmpdir({ git: true, config: { snapshot: true } }) const prev = Global.Path.config ;(Global.Path as { config: string }).config = globalTmp.path - await Config.invalidate() + await clear() try { await writeConfig(globalTmp.path, { $schema: "https://opencode.ai/config.json", @@ -81,7 +91,7 @@ async function check(map: (dir: string) => string) { await Instance.provide({ directory: map(tmp.path), fn: async () => { - const cfg = await Config.get() + const cfg = await load() expect(cfg.snapshot).toBe(true) expect(Instance.directory).toBe(Filesystem.resolve(tmp.path)) expect(Instance.project.id).not.toBe(ProjectID.global) @@ -90,7 +100,7 @@ async function check(map: (dir: string) => string) { } finally { await Instance.disposeAll() ;(Global.Path as { config: string }).config = prev - await Config.invalidate() + await clear() } } @@ -99,7 +109,7 @@ test("loads config with defaults when no files exist", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.username).toBeDefined() }, }) @@ -118,7 +128,7 @@ test("loads JSON config file", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.model).toBe("test/model") expect(config.username).toBe("testuser") }, @@ -156,7 +166,7 @@ test("ignores legacy tui keys in opencode config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.model).toBe("test/model") expect((config as Record).theme).toBeUndefined() expect((config as Record).tui).toBeUndefined() @@ -181,7 +191,7 @@ test("loads JSONC config file", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.model).toBe("test/model") expect(config.username).toBe("testuser") }, @@ -209,7 +219,7 @@ test("jsonc overrides json in the same directory", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.model).toBe("base") expect(config.username).toBe("base") }, @@ -232,7 +242,7 @@ test("handles environment variable substitution", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.username).toBe("test-user") }, }) @@ -264,7 +274,7 @@ test("preserves env variables when adding $schema to config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.username).toBe("secret_value") // Read the file to verify the env variable was preserved @@ -358,7 +368,7 @@ test("handles file inclusion substitution", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.username).toBe("test-user") }, }) @@ -377,7 +387,7 @@ test("handles file inclusion with replacement tokens", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.username).toBe("const out = await Bun.$`echo hi`") }, }) @@ -396,7 +406,7 @@ test("validates config schema and throws on invalid fields", async () => { directory: tmp.path, fn: async () => { // Strict schema should throw an error for invalid fields - await expect(Config.get()).rejects.toThrow() + await expect(load()).rejects.toThrow() }, }) }) @@ -410,7 +420,7 @@ test("throws error for invalid JSON", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(Config.get()).rejects.toThrow() + await expect(load()).rejects.toThrow() }, }) }) @@ -433,7 +443,7 @@ test("handles agent configuration", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test_agent"]).toEqual( expect.objectContaining({ model: "test/model", @@ -464,7 +474,7 @@ test("treats agent variant as model-scoped setting (not provider option)", async await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const agent = config.agent?.["test_agent"] expect(agent?.variant).toBe("xhigh") @@ -494,7 +504,7 @@ test("handles command configuration", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.command?.["test_command"]).toEqual({ template: "test template", description: "test command", @@ -519,7 +529,7 @@ test("migrates autoshare to share field", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.share).toBe("auto") expect(config.autoshare).toBe(true) }, @@ -546,7 +556,7 @@ test("migrates mode field to agent field", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test_mode"]).toEqual({ model: "test/model", temperature: 0.5, @@ -578,7 +588,7 @@ Test agent prompt`, await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]).toEqual( expect.objectContaining({ name: "test", @@ -622,7 +632,7 @@ Nested agent prompt`, await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["helper"]).toMatchObject({ name: "helper", @@ -671,7 +681,7 @@ Nested command template`, await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.command?.["hello"]).toEqual({ description: "Test command", @@ -716,7 +726,7 @@ Nested command template`, await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.command?.["hello"]).toEqual({ description: "Test command", @@ -737,7 +747,7 @@ test("updates config and writes to file", async () => { directory: tmp.path, fn: async () => { const newConfig = { model: "updated/model" } - await Config.update(newConfig as any) + await save(newConfig as any) const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json")) expect(writtenConfig.model).toBe("updated/model") @@ -750,7 +760,7 @@ test("gets config directories", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const dirs = await Config.directories() + const dirs = await listDirs() expect(dirs.length).toBeGreaterThanOrEqual(1) }, }) @@ -780,7 +790,7 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as await Instance.provide({ directory: tmp.path, fn: async () => { - await Config.get() + await load() }, }) } finally { @@ -814,8 +824,8 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await Config.get() - await Config.waitForDependencies() + await load() + await ready() }, }) @@ -996,7 +1006,7 @@ test("resolves scoped npm plugins in config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const pluginEntries = config.plugin ?? [] expect(pluginEntries).toContain("@scope/plugin") }, @@ -1034,7 +1044,7 @@ test("merges plugin arrays from global and local configs", async () => { await Instance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { - const config = await Config.get() + const config = await load() const plugins = config.plugin ?? [] // Should contain both global and local plugins @@ -1070,7 +1080,7 @@ Helper subagent prompt`, await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["helper"]).toMatchObject({ name: "helper", model: "test/model", @@ -1109,7 +1119,7 @@ test("merges instructions arrays from global and local configs", async () => { await Instance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { - const config = await Config.get() + const config = await load() const instructions = config.instructions ?? [] expect(instructions).toContain("global-instructions.md") @@ -1148,7 +1158,7 @@ test("deduplicates duplicate instructions from global and local configs", async await Instance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { - const config = await Config.get() + const config = await load() const instructions = config.instructions ?? [] expect(instructions).toContain("global-only.md") @@ -1193,7 +1203,7 @@ test("deduplicates duplicate plugins from global and local configs", async () => await Instance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { - const config = await Config.get() + const config = await load() const plugins = config.plugin ?? [] // Should contain all unique plugins @@ -1242,7 +1252,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => { await Instance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { - const cfg = await Config.get() + const cfg = await load() const plugins = cfg.plugin ?? [] const origins = cfg.plugin_origins ?? [] const names = plugins.map((item) => Config.pluginSpecifier(item)) @@ -1283,7 +1293,7 @@ test("migrates legacy tools config to permissions - allow", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ bash: "allow", read: "allow", @@ -1314,7 +1324,7 @@ test("migrates legacy tools config to permissions - deny", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ bash: "deny", webfetch: "deny", @@ -1344,7 +1354,7 @@ test("migrates legacy write tool to edit permission", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow", }) @@ -1376,7 +1386,7 @@ test("managed settings override user settings", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.model).toBe("managed/model") expect(config.share).toBe("disabled") expect(config.username).toBe("testuser") @@ -1404,7 +1414,7 @@ test("managed settings override project settings", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.autoupdate).toBe(false) expect(config.disabled_providers).toEqual(["openai"]) }, @@ -1424,7 +1434,7 @@ test("missing managed settings file is not an error", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.model).toBe("user/model") }, }) @@ -1451,7 +1461,7 @@ test("migrates legacy edit tool to edit permission", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ edit: "deny", }) @@ -1480,7 +1490,7 @@ test("migrates legacy patch tool to edit permission", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow", }) @@ -1509,7 +1519,7 @@ test("migrates legacy multiedit tool to edit permission", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ edit: "deny", }) @@ -1541,7 +1551,7 @@ test("migrates mixed legacy tools config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ bash: "allow", edit: "allow", @@ -1576,7 +1586,7 @@ test("merges legacy tools with existing permission config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ glob: "allow", bash: "allow", @@ -1611,7 +1621,7 @@ test("permission config preserves key order", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(Object.keys(config.permission!)).toEqual([ "*", "edit", @@ -1671,7 +1681,7 @@ test("project config can override MCP server enabled status", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() // jira should be enabled (overridden by project config) expect(config.mcp?.jira).toEqual({ type: "remote", @@ -1727,7 +1737,7 @@ test("MCP config deep merges preserving base config properties", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.mcp?.myserver).toEqual({ type: "remote", url: "https://myserver.example.com/mcp", @@ -1778,7 +1788,7 @@ test("local .opencode config can override MCP from project config", async () => await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.mcp?.docs?.enabled).toBe(true) }, }) @@ -2029,7 +2039,7 @@ describe("deduplicatePluginOrigins", () => { await Instance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { - const config = await Config.get() + const config = await load() const plugins = config.plugin ?? [] expect(plugins.some((p) => Config.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true) @@ -2061,7 +2071,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() // Project config should NOT be loaded - model should be default, not "project/model" expect(config.model).not.toBe("project/model") expect(config.username).not.toBe("project-user") @@ -2092,7 +2102,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const directories = await Config.directories() + const directories = await listDirs() // Project .opencode should NOT be in directories list const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path)) expect(hasProjectOpencode).toBe(false) @@ -2117,7 +2127,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { directory: tmp.path, fn: async () => { // Should still get default config (from global or defaults) - const config = await Config.get() + const config = await load() expect(config).toBeDefined() expect(config.username).toBeDefined() }, @@ -2160,7 +2170,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { fn: async () => { // The relative instruction should be skipped without error // We're mainly verifying this doesn't throw and the config loads - const config = await Config.get() + const config = await load() expect(config).toBeDefined() // The instruction should have been skipped (warning logged) // We can't easily test the warning was logged, but we verify @@ -2218,7 +2228,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await Instance.provide({ directory: projectTmp.path, fn: async () => { - const config = await Config.get() + const config = await load() // Should load from OPENCODE_CONFIG_DIR, not project expect(config.model).toBe("configdir/model") }, @@ -2253,7 +2263,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.username).toBe("test_api_key_12345") }, }) @@ -2287,7 +2297,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.username).toBe("secret_key_from_file") }, }) diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index b761d59ea4..529d88bce1 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -7,12 +7,15 @@ import { Config } from "../../src/config/config" import { TuiConfig } from "../../src/config/tui" import { Global } from "../../src/global" import { Filesystem } from "../../src/util/filesystem" +import { AppRuntime } from "../../src/effect/app-runtime" const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const wintest = process.platform === "win32" ? test : test.skip +const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait))) +const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) beforeEach(async () => { - await Config.invalidate(true) + await clear(true) }) afterEach(async () => { @@ -23,7 +26,7 @@ afterEach(async () => { await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {}) await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {}) await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) - await Config.invalidate(true) + await clear(true) }) test("keeps server and tui plugin merge semantics aligned", async () => { @@ -79,7 +82,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const server = await Config.get() + const server = await load() const tui = await TuiConfig.get() const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item)) const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item)) diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index 3ca32bf414..d415d23ebc 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -3,6 +3,9 @@ import { Permission } from "../src/permission" import { Config } from "../src/config/config" import { Instance } from "../src/project/instance" import { tmpdir } from "./fixture/fixture" +import { AppRuntime } from "../src/effect/app-runtime" + +const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) afterEach(async () => { await Instance.disposeAll() @@ -158,7 +161,7 @@ describe("permission.task with real config files", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const ruleset = Permission.fromConfig(config.permission ?? {}) // general and orchestrator-fast should be allowed, code-reviewer denied expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") @@ -183,7 +186,7 @@ describe("permission.task with real config files", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const ruleset = Permission.fromConfig(config.permission ?? {}) // general and code-reviewer should be ask, orchestrator-* denied expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask") @@ -208,7 +211,7 @@ describe("permission.task with real config files", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const ruleset = Permission.fromConfig(config.permission ?? {}) expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") @@ -235,7 +238,7 @@ describe("permission.task with real config files", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const ruleset = Permission.fromConfig(config.permission ?? {}) // Verify task permissions @@ -273,7 +276,7 @@ describe("permission.task with real config files", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const ruleset = Permission.fromConfig(config.permission ?? {}) // Last matching rule wins - "*" deny is last, so all agents are denied @@ -304,7 +307,7 @@ describe("permission.task with real config files", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const ruleset = Permission.fromConfig(config.permission ?? {}) // Evaluate uses findLast - "general" allow comes after "*" deny From b8801dbd22e561e3ddaf83744726d8d98744f255 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 13:12:02 -0400 Subject: [PATCH 5/8] refactor(file): remove async facade exports (#22322) --- packages/opencode/src/cli/cmd/debug/file.ts | 26 ++- packages/opencode/src/file/index.ts | 23 --- packages/opencode/src/server/instance/file.ts | 37 +++- packages/opencode/test/file/fsmonitor.test.ts | 12 +- packages/opencode/test/file/index.test.ts | 166 ++++++++++-------- .../opencode/test/file/path-traversal.test.ts | 22 ++- 6 files changed, 161 insertions(+), 125 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 6faaf399ae..d5e24a0cfa 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -1,4 +1,6 @@ import { EOL } from "os" +import { Effect } from "effect" +import { AppRuntime } from "@/effect/app-runtime" import { File } from "../../../file" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" @@ -15,7 +17,11 @@ const FileSearchCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - const results = await File.search({ query: args.query }) + const results = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.search({ query: args.query })) + }), + ) process.stdout.write(results.join(EOL) + EOL) }) }, @@ -32,7 +38,11 @@ const FileReadCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - const content = await File.read(args.path) + const content = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.read(args.path)) + }), + ) process.stdout.write(JSON.stringify(content, null, 2) + EOL) }) }, @@ -44,7 +54,11 @@ const FileStatusCommand = cmd({ builder: (yargs) => yargs, async handler() { await bootstrap(process.cwd(), async () => { - const status = await File.status() + const status = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.status()) + }), + ) process.stdout.write(JSON.stringify(status, null, 2) + EOL) }) }, @@ -61,7 +75,11 @@ const FileListCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - const files = await File.list(args.path) + const files = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.list(args.path)) + }), + ) process.stdout.write(JSON.stringify(files, null, 2) + EOL) }) }, diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 80ed2b7efb..8dc8516349 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,6 +1,5 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@/filesystem" import { Git } from "@/git" import { Effect, Layer, Context } from "effect" @@ -644,26 +643,4 @@ export namespace File { ) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer)) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export function init() { - return runPromise((svc) => svc.init()) - } - - export async function status() { - return runPromise((svc) => svc.status()) - } - - export async function read(file: string): Promise { - return runPromise((svc) => svc.read(file)) - } - - export async function list(dir?: string) { - return runPromise((svc) => svc.list(dir)) - } - - export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { - return runPromise((svc) => svc.search(input)) - } } diff --git a/packages/opencode/src/server/instance/file.ts b/packages/opencode/src/server/instance/file.ts index a869cf3673..db5e227770 100644 --- a/packages/opencode/src/server/instance/file.ts +++ b/packages/opencode/src/server/instance/file.ts @@ -1,5 +1,6 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" +import { Effect } from "effect" import z from "zod" import { AppRuntime } from "../../effect/app-runtime" import { File } from "../../file" @@ -72,12 +73,18 @@ export const FileRoutes = lazy(() => const dirs = c.req.valid("query").dirs const type = c.req.valid("query").type const limit = c.req.valid("query").limit - const results = await File.search({ - query, - limit: limit ?? 10, - dirs: dirs !== "false", - type, - }) + const results = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => + svc.search({ + query, + limit: limit ?? 10, + dirs: dirs !== "false", + type, + }), + ) + }), + ) return c.json(results) }, ) @@ -133,7 +140,11 @@ export const FileRoutes = lazy(() => ), async (c) => { const path = c.req.valid("query").path - const content = await File.list(path) + const content = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.list(path)) + }), + ) return c.json(content) }, ) @@ -162,7 +173,11 @@ export const FileRoutes = lazy(() => ), async (c) => { const path = c.req.valid("query").path - const content = await File.read(path) + const content = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.read(path)) + }), + ) return c.json(content) }, ) @@ -184,7 +199,11 @@ export const FileRoutes = lazy(() => }, }), async (c) => { - const content = await File.status() + const content = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.status()) + }), + ) return c.json(content) }, ), diff --git a/packages/opencode/test/file/fsmonitor.test.ts b/packages/opencode/test/file/fsmonitor.test.ts index 8cdde014db..699e713c22 100644 --- a/packages/opencode/test/file/fsmonitor.test.ts +++ b/packages/opencode/test/file/fsmonitor.test.ts @@ -1,10 +1,16 @@ import { $ } from "bun" import { describe, expect, test } from "bun:test" +import { Effect } from "effect" import fs from "fs/promises" import path from "path" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdir } from "../fixture/fixture" + +const run = (eff: Effect.Effect) => + Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer)))) +const status = () => run(File.Service.use((svc) => svc.status())) +const read = (file: string) => run(File.Service.use((svc) => svc.read(file))) const wintest = process.platform === "win32" ? test : test.skip @@ -27,7 +33,7 @@ describe("file fsmonitor", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.status() + await status() }, }) @@ -52,7 +58,7 @@ describe("file fsmonitor", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.read("tracked.txt") + await read("tracked.txt") }, }) diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index cd4f023cc7..d8203ac12d 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -1,18 +1,28 @@ import { afterEach, describe, test, expect } from "bun:test" import { $ } from "bun" +import { Effect } from "effect" import path from "path" import fs from "fs/promises" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdir } from "../fixture/fixture" afterEach(async () => { await Instance.disposeAll() }) +const init = () => run(File.Service.use((svc) => svc.init())) +const run = (eff: Effect.Effect) => + Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer)))) +const status = () => run(File.Service.use((svc) => svc.status())) +const read = (file: string) => run(File.Service.use((svc) => svc.read(file))) +const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir))) +const search = (input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) => + run(File.Service.use((svc) => svc.search(input))) + describe("file/index Filesystem patterns", () => { - describe("File.read() - text content", () => { + describe("read() - text content", () => { test("reads text file via Filesystem.readText()", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "test.txt") @@ -21,7 +31,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("test.txt") + const result = await read("test.txt") expect(result.type).toBe("text") expect(result.content).toBe("Hello World") }, @@ -35,7 +45,7 @@ describe("file/index Filesystem patterns", () => { directory: tmp.path, fn: async () => { // Non-existent file should return empty content - const result = await File.read("nonexistent.txt") + const result = await read("nonexistent.txt") expect(result.type).toBe("text") expect(result.content).toBe("") }, @@ -50,7 +60,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("test.txt") + const result = await read("test.txt") expect(result.content).toBe("content with spaces") }, }) @@ -64,7 +74,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("empty.txt") + const result = await read("empty.txt") expect(result.type).toBe("text") expect(result.content).toBe("") }, @@ -79,14 +89,14 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("multiline.txt") + const result = await read("multiline.txt") expect(result.content).toBe("line1\nline2\nline3") }, }) }) }) - describe("File.read() - binary content", () => { + describe("read() - binary content", () => { test("reads binary file via Filesystem.readArrayBuffer()", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "image.png") @@ -96,7 +106,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("image.png") + const result = await read("image.png") expect(result.type).toBe("text") // Images return as text with base64 encoding expect(result.encoding).toBe("base64") expect(result.mimeType).toBe("image/png") @@ -113,7 +123,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("binary.so") + const result = await read("binary.so") expect(result.type).toBe("binary") expect(result.content).toBe("") }, @@ -121,7 +131,7 @@ describe("file/index Filesystem patterns", () => { }) }) - describe("File.read() - Filesystem.mimeType()", () => { + describe("read() - Filesystem.mimeType()", () => { test("detects MIME type via Filesystem.mimeType()", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "test.json") @@ -132,7 +142,7 @@ describe("file/index Filesystem patterns", () => { fn: async () => { expect(Filesystem.mimeType(filepath)).toContain("application/json") - const result = await File.read("test.json") + const result = await read("test.json") expect(result.type).toBe("text") }, }) @@ -161,7 +171,7 @@ describe("file/index Filesystem patterns", () => { }) }) - describe("File.list() - Filesystem.exists() and readText()", () => { + describe("list() - Filesystem.exists() and readText()", () => { test("reads .gitignore via Filesystem.exists() and readText()", async () => { await using tmp = await tmpdir({ git: true }) @@ -171,7 +181,7 @@ describe("file/index Filesystem patterns", () => { const gitignorePath = path.join(tmp.path, ".gitignore") await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8") - // This is used internally in File.list() + // This is used internally in list() expect(await Filesystem.exists(gitignorePath)).toBe(true) const content = await Filesystem.readText(gitignorePath) @@ -204,8 +214,8 @@ describe("file/index Filesystem patterns", () => { const gitignorePath = path.join(tmp.path, ".gitignore") expect(await Filesystem.exists(gitignorePath)).toBe(false) - // File.list() should still work - const nodes = await File.list() + // list() should still work + const nodes = await list() expect(Array.isArray(nodes)).toBe(true) }, }) @@ -244,8 +254,8 @@ describe("file/index Filesystem patterns", () => { // Filesystem.readText() on non-existent file throws await expect(Filesystem.readText(nonExistentPath)).rejects.toThrow() - // But File.read() handles this gracefully - const result = await File.read("does-not-exist.txt") + // But read() handles this gracefully + const result = await read("does-not-exist.txt") expect(result.content).toBe("") }, }) @@ -272,8 +282,8 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - // File.read() handles missing images gracefully - const result = await File.read("broken.png") + // read() handles missing images gracefully + const result = await read("broken.png") expect(result.type).toBe("text") expect(result.content).toBe("") }, @@ -290,7 +300,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("test.ts") + const result = await read("test.ts") expect(result.type).toBe("text") expect(result.content).toBe("export const value = 1") }, @@ -305,7 +315,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("test.mts") + const result = await read("test.mts") expect(result.type).toBe("text") expect(result.content).toBe("export const value = 1") }, @@ -320,7 +330,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("test.sh") + const result = await read("test.sh") expect(result.type).toBe("text") expect(result.content).toBe("#!/usr/bin/env bash\necho hello") }, @@ -335,7 +345,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("Dockerfile") + const result = await read("Dockerfile") expect(result.type).toBe("text") expect(result.content).toBe("FROM alpine:3.20") }, @@ -350,7 +360,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("test.txt") + const result = await read("test.txt") expect(result.encoding).toBeUndefined() expect(result.type).toBe("text") }, @@ -365,7 +375,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("test.jpg") + const result = await read("test.jpg") expect(result.encoding).toBe("base64") expect(result.mimeType).toBe("image/jpeg") }, @@ -380,7 +390,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(File.read("../outside.txt")).rejects.toThrow("Access denied") + await expect(read("../outside.txt")).rejects.toThrow("Access denied") }, }) }) @@ -391,13 +401,13 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(File.read("../outside.txt")).rejects.toThrow("Access denied") + await expect(read("../outside.txt")).rejects.toThrow("Access denied") }, }) }) }) - describe("File.status()", () => { + describe("status()", () => { test("detects modified file", async () => { await using tmp = await tmpdir({ git: true }) const filepath = path.join(tmp.path, "file.txt") @@ -409,7 +419,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.status() + const result = await status() const entry = result.find((f) => f.path === "file.txt") expect(entry).toBeDefined() expect(entry!.status).toBe("modified") @@ -426,7 +436,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.status() + const result = await status() const entry = result.find((f) => f.path === "new.txt") expect(entry).toBeDefined() expect(entry!.status).toBe("added") @@ -447,7 +457,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.status() + const result = await status() // Deleted files appear in both numstat (as "modified") and diff-filter=D (as "deleted") const entries = result.filter((f) => f.path === "gone.txt") expect(entries.some((e) => e.status === "deleted")).toBe(true) @@ -470,7 +480,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.status() + const result = await status() expect(result.some((f) => f.path === "keep.txt" && f.status === "modified")).toBe(true) expect(result.some((f) => f.path === "remove.txt" && f.status === "deleted")).toBe(true) expect(result.some((f) => f.path === "brand-new.txt" && f.status === "added")).toBe(true) @@ -484,7 +494,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.status() + const result = await status() expect(result).toEqual([]) }, }) @@ -496,7 +506,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.status() + const result = await status() expect(result).toEqual([]) }, }) @@ -519,7 +529,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.status() + const result = await status() const entry = result.find((f) => f.path === "data.bin") expect(entry).toBeDefined() expect(entry!.status).toBe("modified") @@ -530,7 +540,7 @@ describe("file/index Filesystem patterns", () => { }) }) - describe("File.list()", () => { + describe("list()", () => { test("returns files and directories with correct shape", async () => { await using tmp = await tmpdir({ git: true }) await fs.mkdir(path.join(tmp.path, "subdir")) @@ -540,7 +550,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const nodes = await File.list() + const nodes = await list() expect(nodes.length).toBeGreaterThanOrEqual(2) for (const node of nodes) { expect(node).toHaveProperty("name") @@ -564,7 +574,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const nodes = await File.list() + const nodes = await list() const dirs = nodes.filter((n) => n.type === "directory") const files = nodes.filter((n) => n.type === "file") // Dirs come first @@ -589,7 +599,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const nodes = await File.list() + const nodes = await list() const names = nodes.map((n) => n.name) expect(names).not.toContain(".git") expect(names).not.toContain(".DS_Store") @@ -608,7 +618,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const nodes = await File.list() + const nodes = await list() const logNode = nodes.find((n) => n.name === "app.log") const tsNode = nodes.find((n) => n.name === "main.ts") const buildNode = nodes.find((n) => n.name === "build") @@ -628,7 +638,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const nodes = await File.list("sub") + const nodes = await list("sub") expect(nodes.length).toBe(2) expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"]) // Paths should be relative to project root (normalize for Windows) @@ -643,7 +653,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(File.list("../outside")).rejects.toThrow("Access denied") + await expect(list("../outside")).rejects.toThrow("Access denied") }, }) }) @@ -655,7 +665,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const nodes = await File.list() + const nodes = await list() expect(nodes.length).toBeGreaterThanOrEqual(1) // Without git, ignored should be false for all for (const node of nodes) { @@ -666,7 +676,7 @@ describe("file/index Filesystem patterns", () => { }) }) - describe("File.search()", () => { + describe("search()", () => { async function setupSearchableRepo() { const tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8") @@ -685,9 +695,9 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() + await init() - const result = await File.search({ query: "", type: "file" }) + const result = await search({ query: "", type: "file" }) expect(result.length).toBeGreaterThan(0) }, }) @@ -699,7 +709,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.search({ query: "main", type: "file" }) + const result = await search({ query: "main", type: "file" }) expect(result.some((f) => f.includes("main"))).toBe(true) }, }) @@ -711,9 +721,9 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() + await init() - const result = await File.search({ query: "", type: "directory" }) + const result = await search({ query: "", type: "directory" }) expect(result.length).toBeGreaterThan(0) // Find first hidden dir index const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1)) @@ -731,9 +741,9 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() + await init() - const result = await File.search({ query: "main", type: "file" }) + const result = await search({ query: "main", type: "file" }) expect(result.some((f) => f.includes("main"))).toBe(true) }, }) @@ -745,9 +755,9 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() + await init() - const result = await File.search({ query: "", type: "file" }) + const result = await search({ query: "", type: "file" }) // Files don't end with / for (const f of result) { expect(f.endsWith("/")).toBe(false) @@ -762,9 +772,9 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() + await init() - const result = await File.search({ query: "", type: "directory" }) + const result = await search({ query: "", type: "directory" }) // Directories end with / for (const d of result) { expect(d.endsWith("/")).toBe(true) @@ -779,9 +789,9 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() + await init() - const result = await File.search({ query: "", type: "file", limit: 2 }) + const result = await search({ query: "", type: "file", limit: 2 }) expect(result.length).toBeLessThanOrEqual(2) }, }) @@ -793,9 +803,9 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() + await init() - const result = await File.search({ query: ".hidden", type: "directory" }) + const result = await search({ query: ".hidden", type: "directory" }) expect(result.length).toBeGreaterThan(0) expect(result[0]).toContain(".hidden") }, @@ -808,19 +818,19 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() - expect(await File.search({ query: "fresh", type: "file" })).toEqual([]) + await init() + expect(await search({ query: "fresh", type: "file" })).toEqual([]) await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8") - const result = await File.search({ query: "fresh", type: "file" }) + const result = await search({ query: "fresh", type: "file" }) expect(result).toContain("fresh.ts") }, }) }) }) - describe("File.read() - diff/patch", () => { + describe("read() - diff/patch", () => { test("returns diff and patch for modified tracked file", async () => { await using tmp = await tmpdir({ git: true }) const filepath = path.join(tmp.path, "file.txt") @@ -832,7 +842,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("file.txt") + const result = await read("file.txt") expect(result.type).toBe("text") expect(result.content).toBe("modified content") expect(result.diff).toBeDefined() @@ -856,7 +866,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("staged.txt") + const result = await read("staged.txt") expect(result.diff).toBeDefined() expect(result.patch).toBeDefined() }, @@ -873,7 +883,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("clean.txt") + const result = await read("clean.txt") expect(result.type).toBe("text") expect(result.content).toBe("unchanged") expect(result.diff).toBeUndefined() @@ -893,10 +903,10 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: one.path, fn: async () => { - await File.init() - const results = await File.search({ query: "a.ts", type: "file" }) + await init() + const results = await search({ query: "a.ts", type: "file" }) expect(results).toContain("a.ts") - const results2 = await File.search({ query: "b.ts", type: "file" }) + const results2 = await search({ query: "b.ts", type: "file" }) expect(results2).not.toContain("b.ts") }, }) @@ -904,10 +914,10 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: two.path, fn: async () => { - await File.init() - const results = await File.search({ query: "b.ts", type: "file" }) + await init() + const results = await search({ query: "b.ts", type: "file" }) expect(results).toContain("b.ts") - const results2 = await File.search({ query: "a.ts", type: "file" }) + const results2 = await search({ query: "a.ts", type: "file" }) expect(results2).not.toContain("a.ts") }, }) @@ -920,8 +930,8 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() - const results = await File.search({ query: "before", type: "file" }) + await init() + const results = await search({ query: "before", type: "file" }) expect(results).toContain("before.ts") }, }) @@ -934,10 +944,10 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() - const results = await File.search({ query: "after", type: "file" }) + await init() + const results = await search({ query: "after", type: "file" }) expect(results).toContain("after.ts") - const stale = await File.search({ query: "before", type: "file" }) + const stale = await search({ query: "before", type: "file" }) expect(stale).not.toContain("before.ts") }, }) diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 44ae8f1543..1f2e45a6ad 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -1,10 +1,16 @@ import { test, expect, describe } from "bun:test" +import { Effect } from "effect" import path from "path" import fs from "fs/promises" import { Filesystem } from "../../src/util/filesystem" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdir } from "../fixture/fixture" + +const run = (eff: Effect.Effect) => + Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer)))) +const read = (file: string) => run(File.Service.use((svc) => svc.read(file))) +const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir))) describe("Filesystem.contains", () => { test("allows paths within project", () => { @@ -32,10 +38,10 @@ describe("Filesystem.contains", () => { }) /* - * Integration tests for File.read() and File.list() path traversal protection. + * Integration tests for read() and list() path traversal protection. * * These tests verify the HTTP API code path is protected. The HTTP endpoints - * in server.ts (GET /file/content, GET /file) call File.read()/File.list() + * in server.ts (GET /file/content, GET /file) call read()/list() * directly - they do NOT go through ReadTool or the agent permission layer. * * This is a SEPARATE code path from ReadTool, which has its own checks. @@ -51,7 +57,7 @@ describe("File.read path traversal protection", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(File.read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory") + await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory") }, }) }) @@ -62,7 +68,7 @@ describe("File.read path traversal protection", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(File.read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow( + await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow( "Access denied: path escapes project directory", ) }, @@ -79,7 +85,7 @@ describe("File.read path traversal protection", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("valid.txt") + const result = await read("valid.txt") expect(result.content).toBe("valid content") }, }) @@ -93,7 +99,7 @@ describe("File.list path traversal protection", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(File.list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory") + await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory") }, }) }) @@ -108,7 +114,7 @@ describe("File.list path traversal protection", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.list("subdir") + const result = await list("subdir") expect(Array.isArray(result)).toBe(true) }, }) From bf50d1c028e973ccc0beffdf568fca417b62f020 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 13 Apr 2026 13:33:13 -0400 Subject: [PATCH 6/8] feat(core): expose workspace adaptors to plugins (#21927) --- .../migration.sql | 16 + .../snapshot.json | 1337 +++++++++++++++++ .../tui/component/dialog-workspace-create.tsx | 48 +- .../src/control-plane/adaptors/index.ts | 56 +- .../src/control-plane/adaptors/worktree.ts | 20 +- packages/opencode/src/control-plane/types.ts | 14 +- .../src/control-plane/workspace.sql.ts | 2 +- .../opencode/src/control-plane/workspace.ts | 9 +- packages/opencode/src/plugin/index.ts | 15 +- .../src/server/instance/middleware.ts | 2 +- .../opencode/src/server/instance/workspace.ts | 28 + .../test/control-plane/adaptors.test.ts | 71 + .../test/plugin/github-copilot-models.test.ts | 3 + .../test/plugin/workspace-adaptor.test.ts | 99 ++ packages/plugin/src/example-workspace.ts | 34 + packages/plugin/src/index.ts | 33 + packages/plugin/tsconfig.json | 1 + 17 files changed, 1745 insertions(+), 43 deletions(-) create mode 100644 packages/opencode/migration/20260410174513_workspace-name/migration.sql create mode 100644 packages/opencode/migration/20260410174513_workspace-name/snapshot.json create mode 100644 packages/opencode/test/control-plane/adaptors.test.ts create mode 100644 packages/opencode/test/plugin/workspace-adaptor.test.ts create mode 100644 packages/plugin/src/example-workspace.ts diff --git a/packages/opencode/migration/20260410174513_workspace-name/migration.sql b/packages/opencode/migration/20260410174513_workspace-name/migration.sql new file mode 100644 index 0000000000..2a27248e41 --- /dev/null +++ b/packages/opencode/migration/20260410174513_workspace-name/migration.sql @@ -0,0 +1,16 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_workspace` ( + `id` text PRIMARY KEY, + `type` text NOT NULL, + `name` text DEFAULT '' NOT NULL, + `branch` text, + `directory` text, + `extra` text, + `project_id` text NOT NULL, + CONSTRAINT `fk_workspace_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +INSERT INTO `__new_workspace`(`id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id`) SELECT `id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id` FROM `workspace`;--> statement-breakpoint +DROP TABLE `workspace`;--> statement-breakpoint +ALTER TABLE `__new_workspace` RENAME TO `workspace`;--> statement-breakpoint +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/packages/opencode/migration/20260410174513_workspace-name/snapshot.json b/packages/opencode/migration/20260410174513_workspace-name/snapshot.json new file mode 100644 index 0000000000..ab70280080 --- /dev/null +++ b/packages/opencode/migration/20260410174513_workspace-name/snapshot.json @@ -0,0 +1,1337 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "b61476b8-3b92-49ae-9fa5-6eef586ed64b", + "prevIds": [ + "f13dfa58-7fb4-47a2-8f6b-dc70258e14ed" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 40cc1013e0..447a1c3258 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -9,6 +9,12 @@ import { setTimeout as sleep } from "node:timers/promises" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" +type Adaptor = { + type: string + name: string + description: string +} + function scoped(sdk: ReturnType, sync: ReturnType, workspaceID: string) { return createOpencodeClient({ baseUrl: sdk.url, @@ -63,9 +69,27 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = const sdk = useSDK() const toast = useToast() const [creating, setCreating] = createSignal() + const [adaptors, setAdaptors] = createSignal() onMount(() => { dialog.setSize("medium") + void (async () => { + const dir = sync.path.directory || sdk.directory + const url = new URL("/experimental/workspace/adaptor", sdk.url) + if (dir) url.searchParams.set("directory", dir) + const res = await sdk + .fetch(url) + .then((x) => x.json() as Promise) + .catch(() => undefined) + if (!res) { + toast.show({ + message: "Failed to load workspace adaptors", + variant: "error", + }) + return + } + setAdaptors(res) + })() }) const options = createMemo(() => { @@ -79,13 +103,21 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = }, ] } - return [ - { - title: "Worktree", - value: "worktree" as const, - description: "Create a local git worktree", - }, - ] + const list = adaptors() + if (!list) { + return [ + { + title: "Loading workspaces...", + value: "loading" as const, + description: "Fetching available workspace adaptors", + }, + ] + } + return list.map((item) => ({ + title: item.name, + value: item.type, + description: item.description, + })) }) const create = async (type: string) => { @@ -113,7 +145,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = skipFilter={true} options={options()} onSelect={(option) => { - if (option.value === "creating") return + if (option.value === "creating" || option.value === "loading") return void create(option.value) }} /> diff --git a/packages/opencode/src/control-plane/adaptors/index.ts b/packages/opencode/src/control-plane/adaptors/index.ts index a43fce2486..291e392eab 100644 --- a/packages/opencode/src/control-plane/adaptors/index.ts +++ b/packages/opencode/src/control-plane/adaptors/index.ts @@ -1,20 +1,52 @@ import { lazy } from "@/util/lazy" -import type { Adaptor } from "../types" +import type { ProjectID } from "@/project/schema" +import type { WorkspaceAdaptor } from "../types" -const ADAPTORS: Record Promise> = { +export type WorkspaceAdaptorEntry = { + type: string + name: string + description: string +} + +const BUILTIN: Record Promise> = { worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor), } -export function getAdaptor(type: string): Promise { - return ADAPTORS[type]() +const state = new Map>() + +export async function getAdaptor(projectID: ProjectID, type: string): Promise { + const custom = state.get(projectID)?.get(type) + if (custom) return custom + + const builtin = BUILTIN[type] + if (builtin) return builtin() + + throw new Error(`Unknown workspace adaptor: ${type}`) } -export function installAdaptor(type: string, adaptor: Adaptor) { - // This is experimental: mostly used for testing right now, but we - // will likely allow this in the future. Need to figure out the - // TypeScript story - - // @ts-expect-error we force the builtin types right now, but we - // will implement a way to extend the types for custom adaptors - ADAPTORS[type] = () => adaptor +export async function listAdaptors(projectID: ProjectID): Promise { + const builtin = await Promise.all( + Object.entries(BUILTIN).map(async ([type, init]) => { + const adaptor = await init() + return { + type, + name: adaptor.name, + description: adaptor.description, + } + }), + ) + const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({ + type, + name: adaptor.name, + description: adaptor.description, + })) + return [...builtin, ...custom] +} + +// Plugins can be loaded per-project so we need to scope them. If you +// want to install a global one pass `ProjectID.global` +export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) { + const adaptors = state.get(projectID) ?? new Map() + adaptors.set(type, adaptor) + state.set(projectID, adaptors) } diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts index 9fb6c74793..6cc4c20a48 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adaptors/worktree.ts @@ -1,18 +1,18 @@ import z from "zod" import { Worktree } from "@/worktree" -import { type Adaptor, WorkspaceInfo } from "../types" +import { type WorkspaceAdaptor, WorkspaceInfo } from "../types" -const Config = WorkspaceInfo.extend({ - name: WorkspaceInfo.shape.name.unwrap(), +const WorktreeConfig = z.object({ + name: WorkspaceInfo.shape.name, branch: WorkspaceInfo.shape.branch.unwrap(), directory: WorkspaceInfo.shape.directory.unwrap(), }) -type Config = z.infer - -export const WorktreeAdaptor: Adaptor = { +export const WorktreeAdaptor: WorkspaceAdaptor = { + name: "Worktree", + description: "Create a git worktree", async configure(info) { - const worktree = await Worktree.makeWorktreeInfo(info.name ?? undefined) + const worktree = await Worktree.makeWorktreeInfo(undefined) return { ...info, name: worktree.name, @@ -21,7 +21,7 @@ export const WorktreeAdaptor: Adaptor = { } }, async create(info) { - const config = Config.parse(info) + const config = WorktreeConfig.parse(info) await Worktree.createFromInfo({ name: config.name, directory: config.directory, @@ -29,11 +29,11 @@ export const WorktreeAdaptor: Adaptor = { }) }, async remove(info) { - const config = Config.parse(info) + const config = WorktreeConfig.parse(info) await Worktree.remove({ directory: config.directory }) }, target(info) { - const config = Config.parse(info) + const config = WorktreeConfig.parse(info) return { type: "local", directory: config.directory, diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index dd17c56d93..4e499e45ea 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -5,8 +5,8 @@ import { WorkspaceID } from "./schema" export const WorkspaceInfo = z.object({ id: WorkspaceID.zod, type: z.string(), + name: z.string(), branch: z.string().nullable(), - name: z.string().nullable(), directory: z.string().nullable(), extra: z.unknown().nullable(), projectID: ProjectID.zod, @@ -24,9 +24,11 @@ export type Target = headers?: HeadersInit } -export type Adaptor = { - configure(input: WorkspaceInfo): WorkspaceInfo | Promise - create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise - remove(config: WorkspaceInfo): Promise - target(config: WorkspaceInfo): Target | Promise +export type WorkspaceAdaptor = { + name: string + description: string + configure(info: WorkspaceInfo): WorkspaceInfo | Promise + create(info: WorkspaceInfo, from?: WorkspaceInfo): Promise + remove(info: WorkspaceInfo): Promise + target(info: WorkspaceInfo): Target | Promise } diff --git a/packages/opencode/src/control-plane/workspace.sql.ts b/packages/opencode/src/control-plane/workspace.sql.ts index 272907da15..a6a4ce2c86 100644 --- a/packages/opencode/src/control-plane/workspace.sql.ts +++ b/packages/opencode/src/control-plane/workspace.sql.ts @@ -6,8 +6,8 @@ import type { WorkspaceID } from "./schema" export const WorkspaceTable = sqliteTable("workspace", { id: text().$type().primaryKey(), type: text().notNull(), + name: text().notNull().default(""), branch: text(), - name: text(), directory: text(), extra: text({ mode: "json" }), project_id: text() diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index bbf79620c1..f330e07b7a 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -9,6 +9,7 @@ import { SyncEvent } from "@/sync" import { Log } from "@/util/log" import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" +import { Slug } from "@opencode-ai/util/slug" import { WorkspaceTable } from "./workspace.sql" import { getAdaptor } from "./adaptors" import { WorkspaceInfo } from "./types" @@ -66,9 +67,9 @@ export namespace Workspace { export const create = fn(CreateInput, async (input) => { const id = WorkspaceID.ascending(input.id) - const adaptor = await getAdaptor(input.type) + const adaptor = await getAdaptor(input.projectID, input.type) - const config = await adaptor.configure({ ...input, id, name: null, directory: null }) + const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null }) const info: Info = { id, @@ -124,7 +125,7 @@ export namespace Workspace { stopSync(id) const info = fromRow(row) - const adaptor = await getAdaptor(row.type) + const adaptor = await getAdaptor(info.projectID, row.type) adaptor.remove(info) Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) return info @@ -162,7 +163,7 @@ export namespace Workspace { log.info("connecting to sync: " + space.id) setStatus(space.id, "connecting") - const adaptor = await getAdaptor(space.type) + const adaptor = await getAdaptor(space.projectID, space.type) const target = await adaptor.target(space) if (target.type === "local") return diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index e0478e0b3c..60942915a3 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -1,4 +1,10 @@ -import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin" +import type { + Hooks, + PluginInput, + Plugin as PluginInstance, + PluginModule, + WorkspaceAdaptor as PluginWorkspaceAdaptor, +} from "@opencode-ai/plugin" import { Config } from "../config/config" import { Bus } from "../bus" import { Log } from "../util/log" @@ -18,6 +24,8 @@ import { makeRuntime } from "@/effect/run-service" import { errorMessage } from "@/util/error" import { PluginLoader } from "./loader" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" +import { registerAdaptor } from "@/control-plane/adaptors" +import type { WorkspaceAdaptor } from "@/control-plane/types" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -132,6 +140,11 @@ export namespace Plugin { project: ctx.project, worktree: ctx.worktree, directory: ctx.directory, + experimental_workspace: { + register(type: string, adaptor: PluginWorkspaceAdaptor) { + registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor) + }, + }, get serverUrl(): URL { return Server.url ?? new URL("http://localhost:4096") }, diff --git a/packages/opencode/src/server/instance/middleware.ts b/packages/opencode/src/server/instance/middleware.ts index 19bd26535a..868131eb82 100644 --- a/packages/opencode/src/server/instance/middleware.ts +++ b/packages/opencode/src/server/instance/middleware.ts @@ -95,7 +95,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware }) } - const adaptor = await getAdaptor(workspace.type) + const adaptor = await getAdaptor(workspace.projectID, workspace.type) const target = await adaptor.target(workspace) if (target.type === "local") { diff --git a/packages/opencode/src/server/instance/workspace.ts b/packages/opencode/src/server/instance/workspace.ts index 4193216541..7cee031975 100644 --- a/packages/opencode/src/server/instance/workspace.ts +++ b/packages/opencode/src/server/instance/workspace.ts @@ -1,13 +1,41 @@ import { Hono } from "hono" import { describeRoute, resolver, validator } from "hono-openapi" import z from "zod" +import { listAdaptors } from "../../control-plane/adaptors" import { Workspace } from "../../control-plane/workspace" import { Instance } from "../../project/instance" import { errors } from "../error" import { lazy } from "../../util/lazy" +const WorkspaceAdaptor = z.object({ + type: z.string(), + name: z.string(), + description: z.string(), +}) + export const WorkspaceRoutes = lazy(() => new Hono() + .get( + "/adaptor", + describeRoute({ + summary: "List workspace adaptors", + description: "List all available workspace adaptors for the current project.", + operationId: "experimental.workspace.adaptor.list", + responses: { + 200: { + description: "Workspace adaptors", + content: { + "application/json": { + schema: resolver(z.array(WorkspaceAdaptor)), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await listAdaptors(Instance.project.id)) + }, + ) .post( "/", describeRoute({ diff --git a/packages/opencode/test/control-plane/adaptors.test.ts b/packages/opencode/test/control-plane/adaptors.test.ts new file mode 100644 index 0000000000..a8e490226b --- /dev/null +++ b/packages/opencode/test/control-plane/adaptors.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test" +import { getAdaptor, registerAdaptor } from "../../src/control-plane/adaptors" +import { ProjectID } from "../../src/project/schema" +import type { WorkspaceInfo } from "../../src/control-plane/types" + +function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInfo { + return { + id: "workspace-test" as WorkspaceInfo["id"], + type, + name: "workspace-test", + branch: null, + directory: null, + extra: null, + projectID, + } +} + +function adaptor(dir: string) { + return { + name: dir, + description: dir, + configure(input: WorkspaceInfo) { + return input + }, + async create() {}, + async remove() {}, + target() { + return { + type: "local" as const, + directory: dir, + } + }, + } +} + +describe("control-plane/adaptors", () => { + test("isolates custom adaptors by project", async () => { + const type = `demo-${Math.random().toString(36).slice(2)}` + const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) + const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) + registerAdaptor(one, type, adaptor("/one")) + registerAdaptor(two, type, adaptor("/two")) + + expect(await (await getAdaptor(one, type)).target(info(one, type))).toEqual({ + type: "local", + directory: "/one", + }) + expect(await (await getAdaptor(two, type)).target(info(two, type))).toEqual({ + type: "local", + directory: "/two", + }) + }) + + test("latest install wins within a project", async () => { + const type = `demo-${Math.random().toString(36).slice(2)}` + const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) + registerAdaptor(id, type, adaptor("/one")) + + expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({ + type: "local", + directory: "/one", + }) + + registerAdaptor(id, type, adaptor("/two")) + + expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({ + type: "local", + directory: "/two", + }) + }) +}) diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 0b67588a7e..33ddef5ddf 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -125,6 +125,9 @@ test("remaps fallback oauth model urls to the enterprise host", async () => { project: {} as never, directory: "", worktree: "", + experimental_workspace: { + register() {}, + }, serverUrl: new URL("https://example.com"), $: {} as never, }) diff --git a/packages/opencode/test/plugin/workspace-adaptor.test.ts b/packages/opencode/test/plugin/workspace-adaptor.test.ts new file mode 100644 index 0000000000..cc098b124b --- /dev/null +++ b/packages/opencode/test/plugin/workspace-adaptor.test.ts @@ -0,0 +1,99 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test" +import path from "path" +import { pathToFileURL } from "url" +import { tmpdir } from "../fixture/fixture" + +const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS +process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" + +const { Plugin } = await import("../../src/plugin/index") +const { Workspace } = await import("../../src/control-plane/workspace") +const { Instance } = await import("../../src/project/instance") + +afterEach(async () => { + await Instance.disposeAll() +}) + +afterAll(() => { + if (disableDefault === undefined) { + delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS + return + } + process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault +}) + +describe("plugin.workspace", () => { + test("plugin can install a workspace adaptor", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const type = `plug-${Math.random().toString(36).slice(2)}` + const file = path.join(dir, "plugin.ts") + const mark = path.join(dir, "created.json") + const space = path.join(dir, "space") + await Bun.write( + file, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "plug",', + ' description: "plugin workspace adaptor",', + " configure(input) {", + ` return { ...input, name: \"plug\", branch: \"plug/main\", directory: ${JSON.stringify(space)} }`, + " },", + " async create(input) {", + ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`, + " },", + " async remove() {},", + " target(input) {", + ' return { type: "local", directory: input.directory }', + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, + ), + ) + + return { mark, space, type } + }, + }) + + const info = await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Plugin.init() + return Workspace.create({ + type: tmp.extra.type, + branch: null, + extra: { key: "value" }, + projectID: Instance.project.id, + }) + }, + }) + + expect(info.type).toBe(tmp.extra.type) + expect(info.name).toBe("plug") + expect(info.branch).toBe("plug/main") + expect(info.directory).toBe(tmp.extra.space) + expect(info.extra).toEqual({ key: "value" }) + expect(JSON.parse(await Bun.file(tmp.extra.mark).text())).toMatchObject({ + type: tmp.extra.type, + name: "plug", + branch: "plug/main", + directory: tmp.extra.space, + extra: { key: "value" }, + }) + }) +}) diff --git a/packages/plugin/src/example-workspace.ts b/packages/plugin/src/example-workspace.ts new file mode 100644 index 0000000000..9253284507 --- /dev/null +++ b/packages/plugin/src/example-workspace.ts @@ -0,0 +1,34 @@ +import type { Plugin } from "@opencode-ai/plugin" +import { mkdir, rm } from "node:fs/promises" + +export const FolderWorkspacePlugin: Plugin = async ({ experimental_workspace }) => { + experimental_workspace.register("folder", { + name: "Folder", + description: "Create a blank folder", + configure(config) { + const rand = "" + Math.random() + + return { + ...config, + directory: `/tmp/folder/folder-${rand}`, + } + }, + async create(config) { + if (!config.directory) return + await mkdir(config.directory, { recursive: true }) + }, + async remove(config) { + await rm(config.directory!, { recursive: true, force: true }) + }, + target(config) { + return { + type: "local", + directory: config.directory!, + } + }, + }) + + return {} +} + +export default FolderWorkspacePlugin diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 1afb55daa7..49d995c6f7 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -24,11 +24,44 @@ export type ProviderContext = { options: Record } +export type WorkspaceInfo = { + id: string + type: string + name: string + branch: string | null + directory: string | null + extra: unknown | null + projectID: string +} + +export type WorkspaceTarget = + | { + type: "local" + directory: string + } + | { + type: "remote" + url: string | URL + headers?: HeadersInit + } + +export type WorkspaceAdaptor = { + name: string + description: string + configure(config: WorkspaceInfo): WorkspaceInfo | Promise + create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise + remove(config: WorkspaceInfo): Promise + target(config: WorkspaceInfo): WorkspaceTarget | Promise +} + export type PluginInput = { client: ReturnType project: Project directory: string worktree: string + experimental_workspace: { + register(type: string, adaptor: WorkspaceAdaptor): void + } serverUrl: URL $: BunShell } diff --git a/packages/plugin/tsconfig.json b/packages/plugin/tsconfig.json index 1173818783..f8e9370d86 100644 --- a/packages/plugin/tsconfig.json +++ b/packages/plugin/tsconfig.json @@ -2,6 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig.json", "extends": "@tsconfig/node22/tsconfig.json", "compilerOptions": { + "rootDir": "src", "outDir": "dist", "module": "nodenext", "declaration": true, From 329fcb040be17a5779c3b88cab9367127e491638 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 13 Apr 2026 17:37:41 +0000 Subject: [PATCH 7/8] chore: generate --- .../snapshot.json | 132 +++++------------- packages/sdk/js/src/v2/gen/sdk.gen.ts | 38 +++++ packages/sdk/js/src/v2/gen/types.gen.ts | 26 +++- packages/sdk/openapi.json | 71 ++++++++-- 4 files changed, 156 insertions(+), 111 deletions(-) diff --git a/packages/opencode/migration/20260410174513_workspace-name/snapshot.json b/packages/opencode/migration/20260410174513_workspace-name/snapshot.json index ab70280080..9adeeecbe8 100644 --- a/packages/opencode/migration/20260410174513_workspace-name/snapshot.json +++ b/packages/opencode/migration/20260410174513_workspace-name/snapshot.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "b61476b8-3b92-49ae-9fa5-6eef586ed64b", - "prevIds": [ - "f13dfa58-7fb4-47a2-8f6b-dc70258e14ed" - ], + "prevIds": ["f13dfa58-7fb4-47a2-8f6b-dc70258e14ed"], "ddl": [ { "name": "account_state", @@ -969,13 +967,9 @@ "table": "event" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -984,13 +978,9 @@ "table": "account_state" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -999,13 +989,9 @@ "table": "workspace" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1014,13 +1000,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1029,13 +1011,9 @@ "table": "part" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1044,13 +1022,9 @@ "table": "permission" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1059,13 +1033,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1074,13 +1044,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1089,13 +1055,9 @@ "table": "session_share" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1104,119 +1066,91 @@ "table": "event" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", @@ -1334,4 +1268,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 7fba60fcef..f297cb18e2 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -29,6 +29,7 @@ import type { ExperimentalConsoleSwitchOrgResponses, ExperimentalResourceListResponses, ExperimentalSessionListResponses, + ExperimentalWorkspaceAdaptorListResponses, ExperimentalWorkspaceCreateErrors, ExperimentalWorkspaceCreateResponses, ExperimentalWorkspaceListResponses, @@ -1086,6 +1087,38 @@ export class Console extends HeyApiClient { } } +export class Adaptor extends HeyApiClient { + /** + * List workspace adaptors + * + * List all available workspace adaptors for the current project. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/workspace/adaptor", + ...options, + ...params, + }) + } +} + export class Workspace extends HeyApiClient { /** * List workspaces @@ -1229,6 +1262,11 @@ export class Workspace extends HeyApiClient { ...params, }) } + + private _adaptor?: Adaptor + get adaptor(): Adaptor { + return (this._adaptor ??= new Adaptor({ client: this.client })) + } } export class Session extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6512f40a2f..83ec3d751a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1772,8 +1772,8 @@ export type ToolList = Array export type Workspace = { id: string type: string + name: string branch: string | null - name: string | null directory: string | null extra: unknown | null projectID: string @@ -2812,6 +2812,30 @@ export type ToolListResponses = { export type ToolListResponse = ToolListResponses[keyof ToolListResponses] +export type ExperimentalWorkspaceAdaptorListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/adaptor" +} + +export type ExperimentalWorkspaceAdaptorListResponses = { + /** + * Workspace adaptors + */ + 200: Array<{ + type: string + name: string + description: string + }> +} + +export type ExperimentalWorkspaceAdaptorListResponse = + ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses] + export type ExperimentalWorkspaceListData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 5369d1c2e5..85443726f3 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1526,6 +1526,62 @@ ] } }, + "/experimental/workspace/adaptor": { + "get": { + "operationId": "experimental.workspace.adaptor.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "List workspace adaptors", + "description": "List all available workspace adaptors for the current project.", + "responses": { + "200": { + "description": "Workspace adaptors", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["type", "name", "description"] + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adaptor.list({\n ...\n})" + } + ] + } + }, "/experimental/workspace": { "post": { "operationId": "experimental.workspace.create", @@ -11885,17 +11941,10 @@ "type": { "type": "string" }, - "branch": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, "name": { + "type": "string" + }, + "branch": { "anyOf": [ { "type": "string" @@ -11927,7 +11976,7 @@ "type": "string" } }, - "required": ["id", "type", "branch", "name", "directory", "extra", "projectID"] + "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"] }, "Worktree": { "type": "object", From 1dc69359d5351c4fb4dfbefef9e87a34c7c15b12 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 13:45:34 -0400 Subject: [PATCH 8/8] refactor(mcp): remove async facade exports (#22324) --- packages/opencode/src/cli/cmd/mcp.ts | 127 ++-- packages/opencode/src/mcp/index.ts | 34 -- .../src/server/instance/experimental.ts | 9 +- packages/opencode/src/server/instance/mcp.ts | 48 +- packages/opencode/test/mcp/headers.test.ts | 65 +- packages/opencode/test/mcp/lifecycle.test.ts | 568 ++++++++++-------- .../test/mcp/oauth-auto-connect.test.ts | 14 +- .../opencode/test/mcp/oauth-browser.test.ts | 25 +- 8 files changed, 501 insertions(+), 389 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index e33ab6a35c..4904db1c31 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -15,7 +15,8 @@ import { Global } from "../../global" import { modify, applyEdits } from "jsonc-parser" import { Filesystem } from "../../util/filesystem" import { Bus } from "../../bus" -import { AppRuntime } from "@/effect/app-runtime" +import { AppRuntime } from "../../effect/app-runtime" +import { Effect } from "effect" function getAuthStatusIcon(status: MCP.AuthStatus): string { switch (status) { @@ -51,6 +52,47 @@ function isMcpRemote(config: McpEntry): config is McpRemote { return isMcpConfigured(config) && config.type === "remote" } +function configuredServers(config: Config.Info) { + return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1])) +} + +function oauthServers(config: Config.Info) { + return configuredServers(config).filter( + (entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false, + ) +} + +async function listState() { + return AppRuntime.runPromise( + Effect.gen(function* () { + const cfg = yield* Config.Service + const mcp = yield* MCP.Service + const config = yield* cfg.get() + const statuses = yield* mcp.status() + const stored = yield* Effect.all( + Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])), + { concurrency: "unbounded" }, + ) + return { config, statuses, stored } + }), + ) +} + +async function authState() { + return AppRuntime.runPromise( + Effect.gen(function* () { + const cfg = yield* Config.Service + const mcp = yield* MCP.Service + const config = yield* cfg.get() + const auth = yield* Effect.all( + Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])), + { concurrency: "unbounded" }, + ) + return { config, auth } + }), + ) +} + export const McpCommand = cmd({ command: "mcp", describe: "manage MCP (Model Context Protocol) servers", @@ -76,13 +118,8 @@ export const McpListCommand = cmd({ UI.empty() prompts.intro("MCP Servers") - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - const mcpServers = config.mcp ?? {} - const statuses = await MCP.status() - - const servers = Object.entries(mcpServers).filter((entry): entry is [string, McpConfigured] => - isMcpConfigured(entry[1]), - ) + const { config, statuses, stored } = await listState() + const servers = configuredServers(config) if (servers.length === 0) { prompts.log.warn("No MCP servers configured") @@ -93,7 +130,7 @@ export const McpListCommand = cmd({ for (const [name, serverConfig] of servers) { const status = statuses[name] const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth - const hasStoredTokens = await MCP.hasStoredTokens(name) + const hasStoredTokens = stored[name] let statusIcon: string let statusText: string @@ -153,15 +190,11 @@ export const McpAuthCommand = cmd({ UI.empty() prompts.intro("MCP OAuth Authentication") - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) + const { config, auth } = await authState() const mcpServers = config.mcp ?? {} + const servers = oauthServers(config) - // Get OAuth-capable servers (remote servers with oauth not explicitly disabled) - const oauthServers = Object.entries(mcpServers).filter( - (entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false, - ) - - if (oauthServers.length === 0) { + if (servers.length === 0) { prompts.log.warn("No OAuth-capable MCP servers configured") prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") prompts.log.info(` @@ -178,19 +211,17 @@ export const McpAuthCommand = cmd({ let serverName = args.name if (!serverName) { // Build options with auth status - const options = await Promise.all( - oauthServers.map(async ([name, cfg]) => { - const authStatus = await MCP.getAuthStatus(name) - const icon = getAuthStatusIcon(authStatus) - const statusText = getAuthStatusText(authStatus) - const url = cfg.url - return { - label: `${icon} ${name} (${statusText})`, - value: name, - hint: url, - } - }), - ) + const options = servers.map(([name, cfg]) => { + const authStatus = auth[name] + const icon = getAuthStatusIcon(authStatus) + const statusText = getAuthStatusText(authStatus) + const url = cfg.url + return { + label: `${icon} ${name} (${statusText})`, + value: name, + hint: url, + } + }) const selected = await prompts.select({ message: "Select MCP server to authenticate", @@ -214,7 +245,8 @@ export const McpAuthCommand = cmd({ } // Check if already authenticated - const authStatus = await MCP.getAuthStatus(serverName) + const authStatus = + auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName)))) if (authStatus === "authenticated") { const confirm = await prompts.confirm({ message: `${serverName} already has valid credentials. Re-authenticate?`, @@ -241,7 +273,7 @@ export const McpAuthCommand = cmd({ }) try { - const status = await MCP.authenticate(serverName) + const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName))) if (status.status === "connected") { spinner.stop("Authentication successful!") @@ -290,22 +322,17 @@ export const McpAuthListCommand = cmd({ UI.empty() prompts.intro("MCP OAuth Status") - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - const mcpServers = config.mcp ?? {} + const { config, auth } = await authState() + const servers = oauthServers(config) - // Get OAuth-capable servers - const oauthServers = Object.entries(mcpServers).filter( - (entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false, - ) - - if (oauthServers.length === 0) { + if (servers.length === 0) { prompts.log.warn("No OAuth-capable MCP servers configured") prompts.outro("Done") return } - for (const [name, serverConfig] of oauthServers) { - const authStatus = await MCP.getAuthStatus(name) + for (const [name, serverConfig] of servers) { + const authStatus = auth[name] const icon = getAuthStatusIcon(authStatus) const statusText = getAuthStatusText(authStatus) const url = serverConfig.url @@ -313,7 +340,7 @@ export const McpAuthListCommand = cmd({ prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) } - prompts.outro(`${oauthServers.length} OAuth-capable server(s)`) + prompts.outro(`${servers.length} OAuth-capable server(s)`) }, }) }, @@ -335,7 +362,7 @@ export const McpLogoutCommand = cmd({ prompts.intro("MCP OAuth Logout") const authPath = path.join(Global.Path.data, "mcp-auth.json") - const credentials = await McpAuth.all() + const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all())) const serverNames = Object.keys(credentials) if (serverNames.length === 0) { @@ -373,7 +400,7 @@ export const McpLogoutCommand = cmd({ return } - await MCP.removeAuth(serverName) + await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName))) prompts.log.success(`Removed OAuth credentials for ${serverName}`) prompts.outro("Done") }, @@ -623,10 +650,18 @@ export const McpDebugCommand = cmd({ prompts.log.info(`URL: ${serverConfig.url}`) // Check stored auth status - const authStatus = await MCP.getAuthStatus(serverName) + const { authStatus, entry } = await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service + return { + authStatus: yield* mcp.getAuthStatus(serverName), + entry: yield* auth.get(serverName), + } + }), + ) prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) - const entry = await McpAuth.get(serverName) if (entry?.tokens) { prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) if (entry.tokens.expiresAt) { diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 696a662c12..fc34143a2c 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -27,7 +27,6 @@ import open from "open" import { Effect, Exit, Layer, Option, Context, Stream } from "effect" import { EffectLogger } from "@/effect/logger" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" @@ -890,37 +889,4 @@ export namespace MCP { Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), ) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - // --- Async facade functions --- - - export const status = async () => runPromise((svc) => svc.status()) - - export const tools = async () => runPromise((svc) => svc.tools()) - - export const prompts = async () => runPromise((svc) => svc.prompts()) - - export const resources = async () => runPromise((svc) => svc.resources()) - - export const add = async (name: string, mcp: Config.Mcp) => runPromise((svc) => svc.add(name, mcp)) - - export const connect = async (name: string) => runPromise((svc) => svc.connect(name)) - - export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name)) - - export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName)) - - export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName)) - - export const finishAuth = async (mcpName: string, authorizationCode: string) => - runPromise((svc) => svc.finishAuth(mcpName, authorizationCode)) - - export const removeAuth = async (mcpName: string) => runPromise((svc) => svc.removeAuth(mcpName)) - - export const supportsOAuth = async (mcpName: string) => runPromise((svc) => svc.supportsOAuth(mcpName)) - - export const hasStoredTokens = async (mcpName: string) => runPromise((svc) => svc.hasStoredTokens(mcpName)) - - export const getAuthStatus = async (mcpName: string) => runPromise((svc) => svc.getAuthStatus(mcpName)) } diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index 978aa03a99..ca8b89fa6a 100644 --- a/packages/opencode/src/server/instance/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -408,7 +408,14 @@ export const ExperimentalRoutes = lazy(() => }, }), async (c) => { - return c.json(await MCP.resources()) + return c.json( + await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* MCP.Service + return yield* mcp.resources() + }), + ), + ) }, ), ) diff --git a/packages/opencode/src/server/instance/mcp.ts b/packages/opencode/src/server/instance/mcp.ts index 1e604c9918..f1c8701c4e 100644 --- a/packages/opencode/src/server/instance/mcp.ts +++ b/packages/opencode/src/server/instance/mcp.ts @@ -3,8 +3,10 @@ import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" import { MCP } from "../../mcp" import { Config } from "../../config/config" +import { AppRuntime } from "../../effect/app-runtime" import { errors } from "../error" import { lazy } from "../../util/lazy" +import { Effect } from "effect" export const McpRoutes = lazy(() => new Hono() @@ -26,7 +28,7 @@ export const McpRoutes = lazy(() => }, }), async (c) => { - return c.json(await MCP.status()) + return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status()))) }, ) .post( @@ -56,7 +58,7 @@ export const McpRoutes = lazy(() => ), async (c) => { const { name, config } = c.req.valid("json") - const result = await MCP.add(name, config) + const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config))) return c.json(result.status) }, ) @@ -84,12 +86,21 @@ export const McpRoutes = lazy(() => }), async (c) => { const name = c.req.param("name") - const supportsOAuth = await MCP.supportsOAuth(name) - if (!supportsOAuth) { + const result = await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* MCP.Service + const supports = yield* mcp.supportsOAuth(name) + if (!supports) return { supports } + return { + supports, + auth: yield* mcp.startAuth(name), + } + }), + ) + if (!result.supports) { return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) } - const result = await MCP.startAuth(name) - return c.json(result) + return c.json(result.auth) }, ) .post( @@ -120,7 +131,7 @@ export const McpRoutes = lazy(() => async (c) => { const name = c.req.param("name") const { code } = c.req.valid("json") - const status = await MCP.finishAuth(name, code) + const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code))) return c.json(status) }, ) @@ -144,12 +155,21 @@ export const McpRoutes = lazy(() => }), async (c) => { const name = c.req.param("name") - const supportsOAuth = await MCP.supportsOAuth(name) - if (!supportsOAuth) { + const result = await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* MCP.Service + const supports = yield* mcp.supportsOAuth(name) + if (!supports) return { supports } + return { + supports, + status: yield* mcp.authenticate(name), + } + }), + ) + if (!result.supports) { return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) } - const status = await MCP.authenticate(name) - return c.json(status) + return c.json(result.status) }, ) .delete( @@ -172,7 +192,7 @@ export const McpRoutes = lazy(() => }), async (c) => { const name = c.req.param("name") - await MCP.removeAuth(name) + await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name))) return c.json({ success: true as const }) }, ) @@ -195,7 +215,7 @@ export const McpRoutes = lazy(() => validator("param", z.object({ name: z.string() })), async (c) => { const { name } = c.req.valid("param") - await MCP.connect(name) + await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name))) return c.json(true) }, ) @@ -218,7 +238,7 @@ export const McpRoutes = lazy(() => validator("param", z.object({ name: z.string() })), async (c) => { const { name } = c.req.valid("param") - await MCP.disconnect(name) + await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name))) return c.json(true) }, ), diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index 69998aaaa8..14c08e3036 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -1,4 +1,6 @@ import { test, expect, mock, beforeEach } from "bun:test" +import { Effect } from "effect" +import type { MCP as MCPNS } from "../../src/mcp/index" // Track what options were passed to each transport constructor const transportCalls: Array<{ @@ -44,8 +46,10 @@ beforeEach(() => { // Import MCP after mocking const { MCP } = await import("../../src/mcp/index") +const { AppRuntime } = await import("../../src/effect/app-runtime") const { Instance } = await import("../../src/project/instance") const { tmpdir } = await import("../fixture/fixture") +const service = MCP.Service as unknown as Effect.Effect test("headers are passed to transports when oauth is enabled (default)", async () => { await using tmp = await tmpdir({ @@ -73,14 +77,21 @@ test("headers are passed to transports when oauth is enabled (default)", async ( directory: tmp.path, fn: async () => { // Trigger MCP initialization - it will fail to connect but we can check the transport options - await MCP.add("test-server", { - type: "remote", - url: "https://example.com/mcp", - headers: { - Authorization: "Bearer test-token", - "X-Custom-Header": "custom-value", - }, - }).catch(() => {}) + await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* service + yield* mcp + .add("test-server", { + type: "remote", + url: "https://example.com/mcp", + headers: { + Authorization: "Bearer test-token", + "X-Custom-Header": "custom-value", + }, + }) + .pipe(Effect.catch(() => Effect.void)) + }), + ) // Both transports should have been created with headers expect(transportCalls.length).toBeGreaterThanOrEqual(1) @@ -106,14 +117,21 @@ test("headers are passed to transports when oauth is explicitly disabled", async fn: async () => { transportCalls.length = 0 - await MCP.add("test-server-no-oauth", { - type: "remote", - url: "https://example.com/mcp", - oauth: false, - headers: { - Authorization: "Bearer test-token", - }, - }).catch(() => {}) + await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* service + yield* mcp + .add("test-server-no-oauth", { + type: "remote", + url: "https://example.com/mcp", + oauth: false, + headers: { + Authorization: "Bearer test-token", + }, + }) + .pipe(Effect.catch(() => Effect.void)) + }), + ) expect(transportCalls.length).toBeGreaterThanOrEqual(1) @@ -137,10 +155,17 @@ test("no requestInit when headers are not provided", async () => { fn: async () => { transportCalls.length = 0 - await MCP.add("test-server-no-headers", { - type: "remote", - url: "https://example.com/mcp", - }).catch(() => {}) + await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* service + yield* mcp + .add("test-server-no-headers", { + type: "remote", + url: "https://example.com/mcp", + }) + .pipe(Effect.catch(() => Effect.void)) + }), + ) expect(transportCalls.length).toBeGreaterThanOrEqual(1) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 3d17d3a0d0..09caa1cd8a 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,4 +1,6 @@ import { test, expect, mock, beforeEach } from "bun:test" +import { Effect } from "effect" +import type { MCP as MCPNS } from "../../src/mcp/index" // --- Mock infrastructure --- @@ -170,7 +172,10 @@ const { tmpdir } = await import("../fixture/fixture") // --- Helper --- -function withInstance(config: Record, fn: () => Promise) { +function withInstance( + config: Record, + fn: (mcp: MCPNS.Interface) => Effect.Effect, +) { return async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -187,7 +192,7 @@ function withInstance(config: Record, fn: () => Promise) { await Instance.provide({ directory: tmp.path, fn: async () => { - await fn() + await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer))) // dispose instance to clean up state between tests await Instance.dispose() }, @@ -201,28 +206,30 @@ function withInstance(config: Record, fn: () => Promise) { test( "tools() reuses cached tool definitions after connect", - withInstance({}, async () => { - lastCreatedClientName = "my-server" - const serverState = getOrCreateClientState("my-server") - serverState.tools = [ - { name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } }, - ] + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "my-server" + const serverState = getOrCreateClientState("my-server") + serverState.tools = [ + { name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } }, + ] - // First: add the server successfully - const addResult = await MCP.add("my-server", { - type: "local", - command: ["echo", "test"], - }) - expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected") + // First: add the server successfully + const addResult = yield* mcp.add("my-server", { + type: "local", + command: ["echo", "test"], + }) + expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected") - expect(serverState.listToolsCalls).toBe(1) + expect(serverState.listToolsCalls).toBe(1) - const toolsA = await MCP.tools() - const toolsB = await MCP.tools() - expect(Object.keys(toolsA).length).toBeGreaterThan(0) - expect(Object.keys(toolsB).length).toBeGreaterThan(0) - expect(serverState.listToolsCalls).toBe(1) - }), + const toolsA = yield* mcp.tools() + const toolsB = yield* mcp.tools() + expect(Object.keys(toolsA).length).toBeGreaterThan(0) + expect(Object.keys(toolsB).length).toBeGreaterThan(0) + expect(serverState.listToolsCalls).toBe(1) + }), + ), ) // ======================================================================== @@ -231,30 +238,32 @@ test( test( "tool change notifications refresh cached tool definitions", - withInstance({}, async () => { - lastCreatedClientName = "status-server" - const serverState = getOrCreateClientState("status-server") + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "status-server" + const serverState = getOrCreateClientState("status-server") - await MCP.add("status-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("status-server", { + type: "local", + command: ["echo", "test"], + }) - const before = await MCP.tools() - expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true) - expect(serverState.listToolsCalls).toBe(1) + const before = yield* mcp.tools() + expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true) + expect(serverState.listToolsCalls).toBe(1) - serverState.tools = [{ name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }] + serverState.tools = [{ name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }] - const handler = Array.from(serverState.notificationHandlers.values())[0] - expect(handler).toBeDefined() - await handler?.() + const handler = Array.from(serverState.notificationHandlers.values())[0] + expect(handler).toBeDefined() + yield* Effect.promise(() => handler?.()) - const after = await MCP.tools() - expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true) - expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false) - expect(serverState.listToolsCalls).toBe(2) - }), + const after = yield* mcp.tools() + expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true) + expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false) + expect(serverState.listToolsCalls).toBe(2) + }), + ), ) // ======================================================================== @@ -270,28 +279,29 @@ test( command: ["echo", "test"], }, }, - async () => { - lastCreatedClientName = "disc-server" - getOrCreateClientState("disc-server") + (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "disc-server" + getOrCreateClientState("disc-server") - await MCP.add("disc-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("disc-server", { + type: "local", + command: ["echo", "test"], + }) - const statusBefore = await MCP.status() - expect(statusBefore["disc-server"]?.status).toBe("connected") + const statusBefore = yield* mcp.status() + expect(statusBefore["disc-server"]?.status).toBe("connected") - await MCP.disconnect("disc-server") + yield* mcp.disconnect("disc-server") - const statusAfter = await MCP.status() - expect(statusAfter["disc-server"]?.status).toBe("disabled") + const statusAfter = yield* mcp.status() + expect(statusAfter["disc-server"]?.status).toBe("disabled") - // Tools should be empty after disconnect - const tools = await MCP.tools() - const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server")) - expect(serverTools.length).toBe(0) - }, + // Tools should be empty after disconnect + const tools = yield* mcp.tools() + const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server")) + expect(serverTools.length).toBe(0) + }), ), ) @@ -304,26 +314,29 @@ test( command: ["echo", "test"], }, }, - async () => { - lastCreatedClientName = "reconn-server" - const serverState = getOrCreateClientState("reconn-server") - serverState.tools = [{ name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } }] + (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "reconn-server" + const serverState = getOrCreateClientState("reconn-server") + serverState.tools = [ + { name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } }, + ] - await MCP.add("reconn-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("reconn-server", { + type: "local", + command: ["echo", "test"], + }) - await MCP.disconnect("reconn-server") - expect((await MCP.status())["reconn-server"]?.status).toBe("disabled") + yield* mcp.disconnect("reconn-server") + expect((yield* mcp.status())["reconn-server"]?.status).toBe("disabled") - // Reconnect - await MCP.connect("reconn-server") - expect((await MCP.status())["reconn-server"]?.status).toBe("connected") + // Reconnect + yield* mcp.connect("reconn-server") + expect((yield* mcp.status())["reconn-server"]?.status).toBe("connected") - const tools = await MCP.tools() - expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true) - }, + const tools = yield* mcp.tools() + expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true) + }), ), ) @@ -335,30 +348,32 @@ test( "add() closes the old client when replacing a server", // Don't put the server in config — add it dynamically so we control // exactly which client instance is "first" vs "second". - withInstance({}, async () => { - lastCreatedClientName = "replace-server" - const firstState = getOrCreateClientState("replace-server") + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "replace-server" + const firstState = getOrCreateClientState("replace-server") - await MCP.add("replace-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("replace-server", { + type: "local", + command: ["echo", "test"], + }) - expect(firstState.closed).toBe(false) + expect(firstState.closed).toBe(false) - // Create new state for second client - clientStates.delete("replace-server") - const secondState = getOrCreateClientState("replace-server") + // Create new state for second client + clientStates.delete("replace-server") + const secondState = getOrCreateClientState("replace-server") - // Re-add should close the first client - await MCP.add("replace-server", { - type: "local", - command: ["echo", "test"], - }) + // Re-add should close the first client + yield* mcp.add("replace-server", { + type: "local", + command: ["echo", "test"], + }) - expect(firstState.closed).toBe(true) - expect(secondState.closed).toBe(false) - }), + expect(firstState.closed).toBe(true) + expect(secondState.closed).toBe(false) + }), + ), ) // ======================================================================== @@ -378,37 +393,38 @@ test( command: ["echo", "bad"], }, }, - async () => { - // Set up good server - const goodState = getOrCreateClientState("good-server") - goodState.tools = [{ name: "good_tool", description: "works", inputSchema: { type: "object", properties: {} } }] + (mcp) => + Effect.gen(function* () { + // Set up good server + const goodState = getOrCreateClientState("good-server") + goodState.tools = [{ name: "good_tool", description: "works", inputSchema: { type: "object", properties: {} } }] - // Set up bad server - will fail on listTools during create() - const badState = getOrCreateClientState("bad-server") - badState.listToolsShouldFail = true + // Set up bad server - will fail on listTools during create() + const badState = getOrCreateClientState("bad-server") + badState.listToolsShouldFail = true - // Add good server first - lastCreatedClientName = "good-server" - await MCP.add("good-server", { - type: "local", - command: ["echo", "good"], - }) + // Add good server first + lastCreatedClientName = "good-server" + yield* mcp.add("good-server", { + type: "local", + command: ["echo", "good"], + }) - // Add bad server - should fail but not affect good server - lastCreatedClientName = "bad-server" - await MCP.add("bad-server", { - type: "local", - command: ["echo", "bad"], - }) + // Add bad server - should fail but not affect good server + lastCreatedClientName = "bad-server" + yield* mcp.add("bad-server", { + type: "local", + command: ["echo", "bad"], + }) - const status = await MCP.status() - expect(status["good-server"]?.status).toBe("connected") - expect(status["bad-server"]?.status).toBe("failed") + const status = yield* mcp.status() + expect(status["good-server"]?.status).toBe("connected") + expect(status["bad-server"]?.status).toBe("failed") - // Good server's tools should still be available - const tools = await MCP.tools() - expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true) - }, + // Good server's tools should still be available + const tools = yield* mcp.tools() + expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true) + }), ), ) @@ -426,21 +442,22 @@ test( enabled: false, }, }, - async () => { - const countBefore = clientCreateCount + (mcp) => + Effect.gen(function* () { + const countBefore = clientCreateCount - await MCP.add("disabled-server", { - type: "local", - command: ["echo", "test"], - enabled: false, - } as any) + yield* mcp.add("disabled-server", { + type: "local", + command: ["echo", "test"], + enabled: false, + } as any) - // No client should have been created - expect(clientCreateCount).toBe(countBefore) + // No client should have been created + expect(clientCreateCount).toBe(countBefore) - const status = await MCP.status() - expect(status["disabled-server"]?.status).toBe("disabled") - }, + const status = yield* mcp.status() + expect(status["disabled-server"]?.status).toBe("disabled") + }), ), ) @@ -457,22 +474,23 @@ test( command: ["echo", "test"], }, }, - async () => { - lastCreatedClientName = "prompt-server" - const serverState = getOrCreateClientState("prompt-server") - serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }] + (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "prompt-server" + const serverState = getOrCreateClientState("prompt-server") + serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }] - await MCP.add("prompt-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("prompt-server", { + type: "local", + command: ["echo", "test"], + }) - const prompts = await MCP.prompts() - expect(Object.keys(prompts).length).toBe(1) - const key = Object.keys(prompts)[0] - expect(key).toContain("prompt-server") - expect(key).toContain("my-prompt") - }, + const prompts = yield* mcp.prompts() + expect(Object.keys(prompts).length).toBe(1) + const key = Object.keys(prompts)[0] + expect(key).toContain("prompt-server") + expect(key).toContain("my-prompt") + }), ), ) @@ -485,22 +503,23 @@ test( command: ["echo", "test"], }, }, - async () => { - lastCreatedClientName = "resource-server" - const serverState = getOrCreateClientState("resource-server") - serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }] + (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "resource-server" + const serverState = getOrCreateClientState("resource-server") + serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }] - await MCP.add("resource-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("resource-server", { + type: "local", + command: ["echo", "test"], + }) - const resources = await MCP.resources() - expect(Object.keys(resources).length).toBe(1) - const key = Object.keys(resources)[0] - expect(key).toContain("resource-server") - expect(key).toContain("my-resource") - }, + const resources = yield* mcp.resources() + expect(Object.keys(resources).length).toBe(1) + const key = Object.keys(resources)[0] + expect(key).toContain("resource-server") + expect(key).toContain("my-resource") + }), ), ) @@ -513,21 +532,22 @@ test( command: ["echo", "test"], }, }, - async () => { - lastCreatedClientName = "prompt-disc-server" - const serverState = getOrCreateClientState("prompt-disc-server") - serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }] + (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "prompt-disc-server" + const serverState = getOrCreateClientState("prompt-disc-server") + serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }] - await MCP.add("prompt-disc-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("prompt-disc-server", { + type: "local", + command: ["echo", "test"], + }) - await MCP.disconnect("prompt-disc-server") + yield* mcp.disconnect("prompt-disc-server") - const prompts = await MCP.prompts() - expect(Object.keys(prompts).length).toBe(0) - }, + const prompts = yield* mcp.prompts() + expect(Object.keys(prompts).length).toBe(0) + }), ), ) @@ -537,12 +557,14 @@ test( test( "connect() on nonexistent server does not throw", - withInstance({}, async () => { - // Should not throw - await MCP.connect("nonexistent") - const status = await MCP.status() - expect(status["nonexistent"]).toBeUndefined() - }), + withInstance({}, (mcp) => + Effect.gen(function* () { + // Should not throw + yield* mcp.connect("nonexistent") + const status = yield* mcp.status() + expect(status["nonexistent"]).toBeUndefined() + }), + ), ) // ======================================================================== @@ -551,10 +573,12 @@ test( test( "disconnect() on nonexistent server does not throw", - withInstance({}, async () => { - await MCP.disconnect("nonexistent") - // Should complete without error - }), + withInstance({}, (mcp) => + Effect.gen(function* () { + yield* mcp.disconnect("nonexistent") + // Should complete without error + }), + ), ) // ======================================================================== @@ -563,10 +587,12 @@ test( test( "tools() returns empty when no MCP servers are configured", - withInstance({}, async () => { - const tools = await MCP.tools() - expect(Object.keys(tools).length).toBe(0) - }), + withInstance({}, (mcp) => + Effect.gen(function* () { + const tools = yield* mcp.tools() + expect(Object.keys(tools).length).toBe(0) + }), + ), ) // ======================================================================== @@ -582,27 +608,28 @@ test( command: ["echo", "test"], }, }, - async () => { - lastCreatedClientName = "fail-connect" - getOrCreateClientState("fail-connect") - connectShouldFail = true - connectError = "Connection refused" + (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "fail-connect" + getOrCreateClientState("fail-connect") + connectShouldFail = true + connectError = "Connection refused" - await MCP.add("fail-connect", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("fail-connect", { + type: "local", + command: ["echo", "test"], + }) - const status = await MCP.status() - expect(status["fail-connect"]?.status).toBe("failed") - if (status["fail-connect"]?.status === "failed") { - expect(status["fail-connect"].error).toContain("Connection refused") - } + const status = yield* mcp.status() + expect(status["fail-connect"]?.status).toBe("failed") + if (status["fail-connect"]?.status === "failed") { + expect(status["fail-connect"].error).toContain("Connection refused") + } - // No tools should be available - const tools = await MCP.tools() - expect(Object.keys(tools).length).toBe(0) - }, + // No tools should be available + const tools = yield* mcp.tools() + expect(Object.keys(tools).length).toBe(0) + }), ), ) @@ -648,28 +675,29 @@ test( command: ["echo", "test"], }, }, - async () => { - lastCreatedClientName = "my.special-server" - const serverState = getOrCreateClientState("my.special-server") - serverState.tools = [ - { name: "tool-a", description: "Tool A", inputSchema: { type: "object", properties: {} } }, - { name: "tool.b", description: "Tool B", inputSchema: { type: "object", properties: {} } }, - ] + (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "my.special-server" + const serverState = getOrCreateClientState("my.special-server") + serverState.tools = [ + { name: "tool-a", description: "Tool A", inputSchema: { type: "object", properties: {} } }, + { name: "tool.b", description: "Tool B", inputSchema: { type: "object", properties: {} } }, + ] - await MCP.add("my.special-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("my.special-server", { + type: "local", + command: ["echo", "test"], + }) - const tools = await MCP.tools() - const keys = Object.keys(tools) + const tools = yield* mcp.tools() + const keys = Object.keys(tools) - // Server name dots should be replaced with underscores - expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true) - // Tool name dots should be replaced with underscores - expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true) - expect(keys.length).toBe(2) - }, + // Server name dots should be replaced with underscores + expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true) + // Tool name dots should be replaced with underscores + expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true) + expect(keys.length).toBe(2) + }), ), ) @@ -679,23 +707,25 @@ test( test( "local stdio transport is closed when connect times out (no process leak)", - withInstance({}, async () => { - lastCreatedClientName = "hanging-server" - getOrCreateClientState("hanging-server") - connectShouldHang = true + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "hanging-server" + getOrCreateClientState("hanging-server") + connectShouldHang = true - const addResult = await MCP.add("hanging-server", { - type: "local", - command: ["node", "fake.js"], - timeout: 100, - }) + const addResult = yield* mcp.add("hanging-server", { + type: "local", + command: ["node", "fake.js"], + timeout: 100, + }) - const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status - expect(serverStatus.status).toBe("failed") - expect(serverStatus.error).toContain("timed out") - // Transport must be closed to avoid orphaned child process - expect(transportCloseCount).toBeGreaterThanOrEqual(1) - }), + const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status + expect(serverStatus.status).toBe("failed") + expect(serverStatus.error).toContain("timed out") + // Transport must be closed to avoid orphaned child process + expect(transportCloseCount).toBeGreaterThanOrEqual(1) + }), + ), ) // ======================================================================== @@ -704,23 +734,25 @@ test( test( "remote transport is closed when connect times out", - withInstance({}, async () => { - lastCreatedClientName = "hanging-remote" - getOrCreateClientState("hanging-remote") - connectShouldHang = true + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "hanging-remote" + getOrCreateClientState("hanging-remote") + connectShouldHang = true - const addResult = await MCP.add("hanging-remote", { - type: "remote", - url: "http://localhost:9999/mcp", - timeout: 100, - oauth: false, - }) + const addResult = yield* mcp.add("hanging-remote", { + type: "remote", + url: "http://localhost:9999/mcp", + timeout: 100, + oauth: false, + }) - const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status - expect(serverStatus.status).toBe("failed") - // Transport must be closed to avoid leaked HTTP connections - expect(transportCloseCount).toBeGreaterThanOrEqual(1) - }), + const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status + expect(serverStatus.status).toBe("failed") + // Transport must be closed to avoid leaked HTTP connections + expect(transportCloseCount).toBeGreaterThanOrEqual(1) + }), + ), ) // ======================================================================== @@ -729,22 +761,24 @@ test( test( "failed remote transport is closed before trying next transport", - withInstance({}, async () => { - lastCreatedClientName = "fail-remote" - getOrCreateClientState("fail-remote") - connectShouldFail = true - connectError = "Connection refused" + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "fail-remote" + getOrCreateClientState("fail-remote") + connectShouldFail = true + connectError = "Connection refused" - const addResult = await MCP.add("fail-remote", { - type: "remote", - url: "http://localhost:9999/mcp", - timeout: 5000, - oauth: false, - }) + const addResult = yield* mcp.add("fail-remote", { + type: "remote", + url: "http://localhost:9999/mcp", + timeout: 5000, + oauth: false, + }) - const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status - expect(serverStatus.status).toBe("failed") - // Both StreamableHTTP and SSE transports should be closed - expect(transportCloseCount).toBeGreaterThanOrEqual(2) - }), + const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status + expect(serverStatus.status).toBe("failed") + // Both StreamableHTTP and SSE transports should be closed + expect(transportCloseCount).toBeGreaterThanOrEqual(2) + }), + ), ) diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 76f825247c..786f1fb464 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -1,4 +1,6 @@ import { test, expect, mock, beforeEach } from "bun:test" +import { Effect } from "effect" +import type { MCP as MCPNS } from "../../src/mcp/index" // Mock UnauthorizedError to match the SDK's class class MockUnauthorizedError extends Error { @@ -122,10 +124,14 @@ test("first connect to OAuth server shows needs_auth instead of failed", async ( await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await MCP.add("test-oauth", { - type: "remote", - url: "https://example.com/mcp", - }) + const result = await Effect.runPromise( + MCP.Service.use((mcp) => + mcp.add("test-oauth", { + type: "remote", + url: "https://example.com/mcp", + }), + ).pipe(Effect.provide(MCP.defaultLayer)), + ) const serverStatus = result.status as Record diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index ee4429be75..b6a32b1e1b 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -1,5 +1,7 @@ import { test, expect, mock, beforeEach } from "bun:test" import { EventEmitter } from "events" +import { Effect } from "effect" +import type { MCP as MCPNS } from "../../src/mcp/index" // Track open() calls and control failure behavior let openShouldFail = false @@ -100,10 +102,12 @@ beforeEach(() => { // Import modules after mocking const { MCP } = await import("../../src/mcp/index") +const { AppRuntime } = await import("../../src/effect/app-runtime") const { Bus } = await import("../../src/bus") const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") const { Instance } = await import("../../src/project/instance") const { tmpdir } = await import("../fixture/fixture") +const service = MCP.Service as unknown as Effect.Effect test("BrowserOpenFailed event is published when open() throws", async () => { await using tmp = await tmpdir({ @@ -136,7 +140,12 @@ test("BrowserOpenFailed event is published when open() throws", async () => { // Run authenticate with a timeout to avoid waiting forever for the callback // Attach a handler immediately so callback shutdown rejections // don't show up as unhandled between tests. - const authPromise = MCP.authenticate("test-oauth-server").catch(() => undefined) + const authPromise = AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* service + return yield* mcp.authenticate("test-oauth-server") + }), + ).catch(() => undefined) // Config.get() can be slow in tests, so give it plenty of time. await new Promise((resolve) => setTimeout(resolve, 2_000)) @@ -185,7 +194,12 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () = }) // Run authenticate with a timeout to avoid waiting forever for the callback - const authPromise = MCP.authenticate("test-oauth-server-2").catch(() => undefined) + const authPromise = AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* service + return yield* mcp.authenticate("test-oauth-server-2") + }), + ).catch(() => undefined) // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window. await new Promise((resolve) => setTimeout(resolve, 2_000)) @@ -230,7 +244,12 @@ test("open() is called with the authorization URL", async () => { openCalledWith = undefined // Run authenticate with a timeout to avoid waiting forever for the callback - const authPromise = MCP.authenticate("test-oauth-server-3").catch(() => undefined) + const authPromise = AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* service + return yield* mcp.authenticate("test-oauth-server-3") + }), + ).catch(() => undefined) // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window. await new Promise((resolve) => setTimeout(resolve, 2_000))