From c2dc7a81700837f6531cf27d75864bd11abcd8f0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 4 May 2026 12:25:58 -0400 Subject: [PATCH 1/2] test(agent): skip InstanceBootstrap in plugin-agent regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin-agent regression test was occasionally hanging for 30s on Windows CI. Root cause is the test going through the full `InstanceLayer.layer` (i.e. `InstanceBootstrap.defaultLayer`), which fork-scopes FileWatcher / LSP / MCP / Snapshot / Vcs / etc. — services with native handles that don't always release within the timeout when the test scope closes on Windows. None of those services are needed to verify "plugin config hook adds an agent and Agent.list reflects it." Move the plugin to a stable repo fixture (test/fixture/agent-plugin.ts) so the test can use `it.instance` with the noop InstanceBootstrap that all other instance-scoped tests use. Manually call `Plugin.Service.init` in the body to drive the only bootstrap step that matters here. Production path exercised is unchanged: plugin load → config hook → agent registration → Agent.list. --- .../agent/plugin-agent-regression.test.ts | 76 +++++-------------- .../test/fixture/agent-plugin.constants.ts | 8 ++ .../opencode/test/fixture/agent-plugin.ts | 17 +++++ 3 files changed, 46 insertions(+), 55 deletions(-) create mode 100644 packages/opencode/test/fixture/agent-plugin.constants.ts create mode 100644 packages/opencode/test/fixture/agent-plugin.ts diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 3ac923c4351e..6e33f9f202ac 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -1,65 +1,31 @@ import { expect } from "bun:test" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Layer } from "effect" import path from "path" import { pathToFileURL } from "url" import { Agent } from "../../src/agent/agent" -import { InstanceRef } from "../../src/effect/instance-ref" -import { InstanceLayer } from "../../src/project/instance-layer" -import { InstanceStore } from "../../src/project/instance-store" -import { tmpdirScoped } from "../fixture/fixture" +import { Plugin } from "../../src/plugin" import { testEffect } from "../lib/effect" +import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants" -const pluginAgent = { - name: "plugin_added", - description: "Added by a plugin via the config hook", - mode: "subagent", -} as const +// The plugin lives in test/fixture/ as a stable file rather than being written +// into a per-test tmpdir. Combined with `it.instance` (which uses the noop +// InstanceBootstrap), this skips FileWatcher / LSP / MCP / etc. — the actual +// source of Windows teardown flakiness — while still exercising the production +// code path that matters: plugin load → config hook → agent registration → +// Agent.list. +const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href -const it = testEffect(Layer.mergeAll(Agent.defaultLayer, InstanceLayer.layer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Plugin.defaultLayer)) -it.live("plugin-registered agents appear in Agent.list", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped() - const pluginFile = path.join(dir, "plugin.ts") - - yield* Effect.promise(async () => { - await Promise.all([ - Bun.write( - pluginFile, - [ - "export default async () => ({", - " config: async (cfg) => {", - " cfg.agent = cfg.agent ?? {}", - ` cfg.agent[${JSON.stringify(pluginAgent.name)}] = {`, - ` description: ${JSON.stringify(pluginAgent.description)},`, - ` mode: ${JSON.stringify(pluginAgent.mode)},`, - " }", - " },", - "})", - "", - ].join("\n"), - ), - Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(pluginFile).href], - }), - ), - ]) - }) - - const agents = yield* InstanceStore.Service.use((store) => - Effect.gen(function* () { - const ctx = yield* store.load({ directory: dir }) - yield* Effect.addFinalizer(() => store.dispose(ctx).pipe(Effect.ignore)) - return yield* Agent.Service.use((svc) => svc.list()).pipe(Effect.provideService(InstanceRef, ctx)) - }), - ) - const added = agents.find((agent) => agent.name === pluginAgent.name) - - expect(added?.description).toBe(pluginAgent.description) - expect(added?.mode).toBe(pluginAgent.mode) - }), +it.instance( + "plugin-registered agents appear in Agent.list", + () => + Effect.gen(function* () { + yield* Plugin.Service.use((p) => p.init()) + const agents = yield* Agent.Service.use((svc) => svc.list()) + const added = agents.find((agent) => agent.name === PLUGIN_AGENT.name) + expect(added?.description).toBe(PLUGIN_AGENT.description) + expect(added?.mode).toBe(PLUGIN_AGENT.mode) + }), + { config: { plugin: [pluginUrl] } }, ) diff --git a/packages/opencode/test/fixture/agent-plugin.constants.ts b/packages/opencode/test/fixture/agent-plugin.constants.ts new file mode 100644 index 000000000000..bedf9ea69f1e --- /dev/null +++ b/packages/opencode/test/fixture/agent-plugin.constants.ts @@ -0,0 +1,8 @@ +// Mirrors the values applied by `agent-plugin.ts` so the test can assert +// against the same literals. Kept in a separate file because every export in +// `agent-plugin.ts` must be a plugin function (the loader throws otherwise). +export const PLUGIN_AGENT = { + name: "plugin_added", + description: "Added by a plugin via the config hook", + mode: "subagent", +} as const diff --git a/packages/opencode/test/fixture/agent-plugin.ts b/packages/opencode/test/fixture/agent-plugin.ts new file mode 100644 index 000000000000..a494109dc09b --- /dev/null +++ b/packages/opencode/test/fixture/agent-plugin.ts @@ -0,0 +1,17 @@ +// Stable test fixture for test/agent/plugin-agent-regression.test.ts. Lives in +// the repo (not a tmpdir) so the test can skip the heavy InstanceBootstrap +// chain (FileWatcher / LSP / MCP / etc.) — that's the actual source of Windows +// teardown flakiness, not the plugin import path itself. +// +// Exports must all be functions; the plugin loader throws on any export that +// isn't (`getLegacyPlugins` in src/plugin/index.ts). Constants for the test +// live alongside this file in `agent-plugin.constants.ts`. +export default async () => ({ + config: async (cfg: { agent?: Record }) => { + cfg.agent = cfg.agent ?? {} + cfg.agent["plugin_added"] = { + description: "Added by a plugin via the config hook", + mode: "subagent", + } + }, +}) From f906e8d5907e51f34b04214437156c538fc400af Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 4 May 2026 13:02:28 -0400 Subject: [PATCH 2/2] test(agent): trim plugin-agent fixture comments --- .../test/agent/plugin-agent-regression.test.ts | 9 +++------ .../opencode/test/fixture/agent-plugin.constants.ts | 4 +--- packages/opencode/test/fixture/agent-plugin.ts | 11 +++-------- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 6e33f9f202ac..dff972d100ef 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -7,12 +7,9 @@ import { Plugin } from "../../src/plugin" import { testEffect } from "../lib/effect" import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants" -// The plugin lives in test/fixture/ as a stable file rather than being written -// into a per-test tmpdir. Combined with `it.instance` (which uses the noop -// InstanceBootstrap), this skips FileWatcher / LSP / MCP / etc. — the actual -// source of Windows teardown flakiness — while still exercising the production -// code path that matters: plugin load → config hook → agent registration → -// Agent.list. +// `it.instance` skips InstanceBootstrap so FileWatcher / LSP / MCP don't spin +// up — those services hang during scope teardown on Windows and aren't needed +// to verify plugin → config hook → Agent.list. const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Plugin.defaultLayer)) diff --git a/packages/opencode/test/fixture/agent-plugin.constants.ts b/packages/opencode/test/fixture/agent-plugin.constants.ts index bedf9ea69f1e..9dd5f3910e05 100644 --- a/packages/opencode/test/fixture/agent-plugin.constants.ts +++ b/packages/opencode/test/fixture/agent-plugin.constants.ts @@ -1,6 +1,4 @@ -// Mirrors the values applied by `agent-plugin.ts` so the test can assert -// against the same literals. Kept in a separate file because every export in -// `agent-plugin.ts` must be a plugin function (the loader throws otherwise). +// Separate file because every export in `agent-plugin.ts` must be a function. export const PLUGIN_AGENT = { name: "plugin_added", description: "Added by a plugin via the config hook", diff --git a/packages/opencode/test/fixture/agent-plugin.ts b/packages/opencode/test/fixture/agent-plugin.ts index a494109dc09b..892f63646626 100644 --- a/packages/opencode/test/fixture/agent-plugin.ts +++ b/packages/opencode/test/fixture/agent-plugin.ts @@ -1,11 +1,6 @@ -// Stable test fixture for test/agent/plugin-agent-regression.test.ts. Lives in -// the repo (not a tmpdir) so the test can skip the heavy InstanceBootstrap -// chain (FileWatcher / LSP / MCP / etc.) — that's the actual source of Windows -// teardown flakiness, not the plugin import path itself. -// -// Exports must all be functions; the plugin loader throws on any export that -// isn't (`getLegacyPlugins` in src/plugin/index.ts). Constants for the test -// live alongside this file in `agent-plugin.constants.ts`. +// Every export in this file must be a plugin function — `getLegacyPlugins` +// (src/plugin/index.ts) throws on anything else. Test constants live in +// `agent-plugin.constants.ts`. export default async () => ({ config: async (cfg: { agent?: Record }) => { cfg.agent = cfg.agent ?? {}