From 2311f202cf97d946ca046b4d978de2a903c29ac8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 14 Jun 2026 09:14:14 -0400 Subject: [PATCH 1/2] test(core): add suite to CI --- packages/core/src/database/schema.gen.ts | 32 ++---- .../core/test/plugin/provider-azure.test.ts | 59 +--------- .../provider-cloudflare-workers-ai.test.ts | 65 +---------- .../core/test/plugin/provider-gitlab.test.ts | 103 +----------------- packages/core/test/public-opencode.test.ts | 10 +- packages/core/test/session-runner.test.ts | 4 +- packages/core/test/tool-edit.test.ts | 2 +- packages/core/test/tool-write.test.ts | 2 +- turbo.json | 5 + 9 files changed, 28 insertions(+), 254 deletions(-) diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 5c044ec60f..5190e58384 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -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\`);`) diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index c4bdd806c9..22c54ceda1 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -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* () { diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 208ab8710d..d4e26aafd1 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -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* () { diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index dab52a1f7f..62b965ab27 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -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[] = [] -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 diff --git a/packages/core/test/public-opencode.test.ts b/packages/core/test/public-opencode.test.ts index c5f90e92c4..a62b03f22a 100644 --- a/packages/core/test/public-opencode.test.ts +++ b/packages/core/test/public-opencode.test.ts @@ -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, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index af17de1759..cfec091861 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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) }) diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 57a354fc7c..d8f96a5803 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -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.", diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index de5c7c264a..a81b32d37d 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -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.", diff --git a/turbo.json b/turbo.json index 220cd96e58..f4ea931b9b 100644 --- a/turbo.json +++ b/turbo.json @@ -13,6 +13,11 @@ "outputs": [], "passThroughEnv": ["*"] }, + "@opencode-ai/core#test": { + "dependsOn": ["^build"], + "outputs": [], + "passThroughEnv": ["*"] + }, "@opencode-ai/app#test": { "dependsOn": ["^build"], "outputs": [] From 02051e9cec9dc3b5652fe6b54f6be4072757bb45 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 14 Jun 2026 13:28:48 -0400 Subject: [PATCH 2/2] 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. --- packages/opencode/src/plugin/index.ts | 5 +- packages/opencode/src/server/server.ts | 20 ++++--- .../test/server/httpapi-listen.test.ts | 53 +++++++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index fb36c75cc0..0f71b39a9d 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -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 = { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index e8958c7ba7..5145e26d6c 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -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 { const listener = await Effect.runPromise(listenEffect(opts)) @@ -84,15 +84,14 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect) { +function makeStop(state: ListenerState, unpublishMdns: Effect.Effect, 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* () { diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index 805c11d62a..585c59cb43 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -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> | 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()