Compare commits

...

2 Commits

Author SHA1 Message Date
Dax Raad 02051e9cec fix(plugin): reuse active server for client requests
Temporarily route plugin SDK calls through the active listener so they reuse its instance store instead of initializing plugins again through the default app. Keep the in-process fallback for commands without a listener. This is intentionally a narrow bridge until v2 owns plugin clients in the correct shared server runtime.
2026-06-14 13:28:48 -04:00
Dax Raad 2311f202cf test(core): add suite to CI 2026-06-14 09:14:14 -04:00
12 changed files with 98 additions and 262 deletions
+8 -24
View File
@@ -241,32 +241,16 @@ export default {
`)
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
yield* tx.run(
`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`,
)
yield* tx.run(
`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`,
)
yield* tx.run(`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`)
yield* tx.run(`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
yield* tx.run(
`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
)
yield* tx.run(`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`)
yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`)
yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`)
yield* tx.run(`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`)
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
@@ -1,35 +1,10 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Database } from "@opencode-ai/core/database/database"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const database = Database.layerFromPath(":memory:").pipe(Layer.fresh)
const preferences = Credential.layer.pipe(Layer.provide(database))
const accounts = Layer.merge(
Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)),
preferences,
)
const itWithAccount = testEffect(
Catalog.locationLayer.pipe(
Layer.provideMerge(accounts),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
Layer.provideMerge(npmLayer),
),
)
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
describe("AzurePlugin", () => {
it.effect("resolves resourceName from env", () =>
@@ -73,36 +48,6 @@ describe("AzurePlugin", () => {
),
)
itWithAccount.effect("prefers account resourceName over env", () =>
withEnv(
{
AZURE_RESOURCE_NAME: "from-env",
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* credentials.create({
integrationID: Integration.ID.make("azure"),
value: new Credential.Key({
type: "key",
key: "key",
metadata: { resourceName: "from-account" },
}),
})
yield* plugin.add(AzurePlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.api = { type: "aisdk", package: "@ai-sdk/azure" }
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-account")
}),
),
)
it.effect("falls back to env when configured resourceName is blank", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
@@ -1,36 +1,11 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Database } from "@opencode-ai/core/database/database"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Location } from "@opencode-ai/core/location"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper"
const database = Database.layerFromPath(":memory:").pipe(Layer.fresh)
const preferences = Credential.layer.pipe(Layer.provide(database))
const accounts = Layer.merge(
Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)),
preferences,
)
const itWithAccount = testEffect(
Catalog.locationLayer.pipe(
Layer.provideMerge(accounts),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
Layer.provideMerge(npmLayer),
),
)
import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper"
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
@@ -126,42 +101,6 @@ describe("CloudflareWorkersAIPlugin", () => {
),
)
itWithAccount.effect("falls back to account metadata when account env is absent", () =>
withEnv(
{
CLOUDFLARE_ACCOUNT_ID: undefined,
CLOUDFLARE_API_KEY: undefined,
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* credentials.create({
integrationID: Integration.ID.make("cloudflare-workers-ai"),
value: new Credential.Key({
type: "key",
key: "account-key",
metadata: { accountId: "account-acct" },
}),
})
yield* plugin.add(CloudflareWorkersAIPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.api = { type: "aisdk", package: "test-provider" }
}),
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).request.body).toMatchObject(
{
apiKey: "account-key",
accountId: "account-acct",
},
)
}),
),
)
it.effect("uses env account ID over configured account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
Effect.gen(function* () {
@@ -1,26 +1,10 @@
import { describe, expect, mock } from "bun:test"
import { Effect, Layer } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Database } from "@opencode-ai/core/database/database"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { it, model, npmLayer, withEnv } from "./provider-helper"
import { it, model, withEnv } from "./provider-helper"
const gitlabSDKOptions: Record<string, unknown>[] = []
const database = Database.layerFromPath(":memory:").pipe(Layer.fresh)
const preferences = Credential.layer.pipe(Layer.provide(database))
const accounts = Layer.merge(
Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)),
preferences,
)
void mock.module("gitlab-ai-provider", () => ({
VERSION: "test-version",
@@ -35,17 +19,6 @@ void mock.module("gitlab-ai-provider", () => ({
isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact",
}))
const itWithAccount = testEffect(
Catalog.locationLayer.pipe(
Layer.provideMerge(accounts),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/") }))),
),
Layer.provideMerge(npmLayer),
),
)
describe("GitLabPlugin", () => {
it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () =>
withEnv(
@@ -163,78 +136,6 @@ describe("GitLabPlugin", () => {
}),
)
itWithAccount.effect("uses active account API token over GITLAB_TOKEN", () =>
withEnv(
{
GITLAB_TOKEN: "env-token",
},
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* credentials.create({
integrationID: Integration.ID.make("gitlab"),
value: new Credential.Key({ type: "key", key: "account-token" }),
})
yield* plugin.add(GitLabPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("gitlab", "claude"),
package: "gitlab-ai-provider",
options: provider.request.body,
},
{},
)
expect(gitlabSDKOptions[0].apiKey).toBe("account-token")
}),
),
)
itWithAccount.effect("uses active account OAuth access token when no API token exists", () =>
withEnv(
{
GITLAB_TOKEN: undefined,
},
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* credentials.create({
integrationID: Integration.ID.make("gitlab"),
value: new Credential.OAuth({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
refresh: "refresh-token",
access: "account-oauth-token",
expires: 9999999999999,
}),
})
yield* plugin.add(GitLabPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("gitlab", "claude"),
package: "gitlab-ai-provider",
options: provider.request.body,
},
{},
)
expect(gitlabSDKOptions[0].apiKey).toBe("account-oauth-token")
}),
),
)
it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
+5 -5
View File
@@ -50,7 +50,7 @@ describe("public native OpenCode API", () => {
Effect.gen(function* () {
yield* writeProvider(tmp.path)
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_available")
const sessionID = Session.ID.create()
const model = ref({ variant: "fast" })
yield* opencode.sessions.create({
id: sessionID,
@@ -75,8 +75,8 @@ describe("public native OpenCode API", () => {
yield* writeProvider(available.path)
yield* writeProvider(disabled.path, true)
const opencode = yield* OpenCode.Service
const availableID = Session.ID.make("ses_public_switch_exact_available")
const disabledID = Session.ID.make("ses_public_switch_exact_disabled")
const availableID = Session.ID.create()
const disabledID = Session.ID.create()
yield* opencode.sessions.create({
id: availableID,
location: Location.Ref.make({ directory: AbsolutePath.make(available.path) }),
@@ -112,7 +112,7 @@ describe("public native OpenCode API", () => {
Effect.gen(function* () {
yield* writeProvider(tmp.path)
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_variant")
const sessionID = Session.ID.create()
const selected = ref({ variant: "fast" })
yield* opencode.sessions.create({
id: sessionID,
@@ -134,7 +134,7 @@ describe("public native OpenCode API", () => {
it.effect("preserves the typed not-found error for a missing Session", () =>
Effect.gen(function* () {
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_missing")
const sessionID = Session.ID.create()
const error = yield* opencode.sessions
.switchModel({
sessionID,
+2 -2
View File
@@ -704,7 +704,7 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Moved, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
location: { directory: AbsolutePath.make("/moved") },
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
})
expect(
yield* db
@@ -762,7 +762,7 @@ describe("SessionRunnerLLM", () => {
.publish(SessionEvent.Moved, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
location: { directory: AbsolutePath.make("/moved") },
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
})
.pipe(Effect.asVoid)
})
+1 -1
View File
@@ -404,7 +404,7 @@ test("keeps the locked edit schema, semantics docstring, and deferred TODOs visi
expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["newString", "oldString", "path", "replaceAll"])
expect(source).toContain(
"Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.",
"absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.",
)
for (const todo of [
"Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.",
+1 -1
View File
@@ -279,7 +279,7 @@ test("keeps the locked write schema, semantics docstring, and deferred UX TODOs
expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["content", "path"])
expect(source).toContain(
"Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.",
"absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.",
)
for (const todo of [
"Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.",
+3 -2
View File
@@ -138,11 +138,12 @@ export const layer = Layer.effect(
const { Server } = yield* Effect.promise(() => import("../server/server"))
const serverUrl = Server.url
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
baseUrl: serverUrl?.toString() ?? "http://localhost:4096",
directory: ctx.directory,
headers: ServerAuth.headers(),
fetch: async (...args) => Server.Default().app.fetch(...args),
...(serverUrl ? {} : { fetch: async (...args) => Server.Default().app.fetch(...args) }),
})
const cfg = yield* config.get()
const input: PluginInput = {
+14 -6
View File
@@ -67,7 +67,7 @@ export async function openapi() {
return OpenApi.fromApi(PublicApi)
}
export let url: URL
export let url: URL | undefined
export async function listen(opts: ListenOptions): Promise<Listener> {
const listener = await Effect.runPromise(listenEffect(opts))
@@ -84,15 +84,14 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unkno
const state = yield* startWithPortFallback(opts)
const address = yield* tcpAddress(state)
const listenerUrl = makeURL(opts.hostname, address.port)
url = listenerUrl
const unpublishMdns = yield* setupMdns(opts, address.port, state.scope)
url = listenerUrl
return {
hostname: opts.hostname,
port: address.port,
url: listenerUrl,
stop: yield* makeStop(state, unpublishMdns),
stop: yield* makeStop(state, unpublishMdns, listenerUrl),
}
},
)
@@ -169,10 +168,19 @@ function setupMdns(opts: ListenOptions, port: number, scope: Scope.Scope) {
})
}
function makeStop(state: ListenerState, unpublishMdns: Effect.Effect<void>) {
function makeStop(state: ListenerState, unpublishMdns: Effect.Effect<void>, listenerUrl: URL) {
return Effect.gen(function* () {
const forceCloseOnce = yield* Effect.cached(forceClose(state).pipe(Effect.ignore))
const closeScopeOnce = yield* Effect.cached(Scope.close(state.scope, Exit.void).pipe(Effect.ignore))
const closeScopeOnce = yield* Effect.cached(
Scope.close(state.scope, Exit.void).pipe(
Effect.ignore,
Effect.ensuring(
Effect.sync(() => {
if (url === listenerUrl) url = undefined
}),
),
),
)
return (close?: boolean) =>
Effect.gen(function* () {
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test"
import net from "node:net"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Server } from "../../src/server/server"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
@@ -300,6 +302,57 @@ describe("HttpApi Server.listen", () => {
expect(output).not.toContain("Sent HTTP response")
})
test("plugin client requests reuse the listening server instance", async () => {
await using tmp = await tmpdir({
init: async (directory) => {
const plugin = path.join(directory, "plugin.ts")
const initialized = path.join(directory, "initialized.txt")
const completed = path.join(directory, "completed.txt")
await Bun.write(
plugin,
[
"export default async function plugin(input) {",
` await Bun.write(${JSON.stringify(initialized)}, (await Bun.file(${JSON.stringify(initialized)}).text().catch(() => "")) + "initialized\\n")`,
" setTimeout(async () => {",
" await input.client.config.get()",
` await Bun.write(${JSON.stringify(completed)}, "completed")`,
" }, 50)",
" return {}",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(directory, "opencode.json"),
JSON.stringify({ formatter: false, lsp: false, plugin: [pathToFileURL(plugin).href] }),
)
return { initialized, completed }
},
})
const previous = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
let listener: Awaited<ReturnType<typeof startListener>> | undefined
try {
listener = await startListener()
const response = await fetch(new URL("/config", listener.url), {
headers: { authorization: authorization(), "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
await withTimeout(
(async () => {
while (!(await Bun.file(tmp.extra.completed).exists())) await Bun.sleep(10)
})(),
5_000,
"timed out waiting for plugin client request",
)
expect(await Bun.file(tmp.extra.initialized).text()).toBe("initialized\n")
} finally {
if (listener) await stop(listener, "timed out cleaning up plugin client listener").catch(() => undefined)
if (previous === undefined) delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
else process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = previous
}
})
test("port 0 prefers 4096 when free", async () => {
if (!(await isPortFree(4096))) return
const listener = await startListener()
+5
View File
@@ -13,6 +13,11 @@
"outputs": [],
"passThroughEnv": ["*"]
},
"@opencode-ai/core#test": {
"dependsOn": ["^build"],
"outputs": [],
"passThroughEnv": ["*"]
},
"@opencode-ai/app#test": {
"dependsOn": ["^build"],
"outputs": []