fix(instance): run bootstrap from instance store (#25475)

This commit is contained in:
Kit Langton
2026-05-02 19:33:38 -04:00
committed by GitHub
parent 36007aecf4
commit f98053c34e
42 changed files with 540 additions and 249 deletions
@@ -0,0 +1,51 @@
import { afterEach, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
afterEach(async () => {
await disposeAllInstances()
})
test("plugin-registered agents appear in Agent.list", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginFile = path.join(dir, "plugin.ts")
await Bun.write(
pluginFile,
[
"export default async () => ({",
" config: async (cfg) => {",
" cfg.agent = cfg.agent ?? {}",
" cfg.agent.plugin_added = {",
' description: "Added by a plugin via the config hook",',
' mode: "subagent",',
" }",
" },",
"})",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(pluginFile).href],
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
const added = agents.find((agent) => agent.name === "plugin_added")
expect(added?.description).toBe("Added by a plugin via the config hook")
expect(added?.mode).toBe("subagent")
},
})
})
+32 -21
View File
@@ -12,8 +12,9 @@ import { Account } from "../../src/account/account"
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Env } from "../../src/env"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { provideTestInstance, provideTmpdirInstance } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import { InstanceRuntime } from "@/project/instance-runtime"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
@@ -41,6 +42,12 @@ const emptyAuth = Layer.mock(Auth.Service)({
const testFlock = EffectFlock.defaultLayer
const noopNpm = Layer.mock(Npm.Service)({
install: () => Effect.void,
add: () => Effect.die("not implemented"),
which: () => Effect.succeed(Option.none()),
})
const layer = Config.layer.pipe(
Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
@@ -48,7 +55,7 @@ const layer = Config.layer.pipe(
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(Npm.defaultLayer),
Layer.provide(noopNpm),
)
const it = testEffect(layer)
@@ -57,9 +64,17 @@ const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe
const save = (config: Config.Info) =>
Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
const saveGlobal = (config: Config.Info) =>
Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(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)))
Effect.runPromise(
Config.Service.use((svc) => svc.updateGlobal(config)).pipe(
Effect.map((result) => result.info),
Effect.scoped,
Effect.provide(layer),
),
)
const clear = async (wait = false) => {
await Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer)))
if (wait) await InstanceRuntime.disposeAllInstances()
}
const listDirs = () =>
Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer)))
const ready = () =>
@@ -108,7 +123,7 @@ async function check(map: (dir: string) => string) {
},
})
} finally {
await disposeAllInstances()
await InstanceRuntime.disposeAllInstances()
;(Global.Path as { config: string }).config = prev
await clear()
}
@@ -483,6 +498,7 @@ test("resolves env templates in account config with account token", async () =>
Layer.provide(emptyAuth),
Layer.provide(fakeAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
)
try {
@@ -493,7 +509,7 @@ test("resolves env templates in account config with account token", async () =>
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
}),
),
).pipe(Effect.scoped, Effect.provide(layer), Effect.provide(Npm.defaultLayer), Effect.runPromise)
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
} finally {
if (originalControlToken !== undefined) {
process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken
@@ -550,7 +566,7 @@ test("validates config schema and throws on invalid fields", async () => {
})
},
})
await Instance.provide({
await provideTestInstance({
directory: tmp.path,
fn: async () => {
// Strict schema should throw an error for invalid fields
@@ -565,7 +581,7 @@ test("throws error for invalid JSON", async () => {
await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }")
},
})
await Instance.provide({
await provideTestInstance({
directory: tmp.path,
fn: async () => {
await expect(load()).rejects.toThrow()
@@ -986,11 +1002,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
const prev = process.env.OPENCODE_CONFIG_DIR
process.env.OPENCODE_CONFIG_DIR = tmp.extra
const noopNpm = Layer.mock(Npm.Service)({
install: () => Effect.void,
add: () => Effect.die("not implemented"),
which: () => Effect.succeed(Option.none()),
})
const testLayer = Config.layer.pipe(
Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
@@ -1061,7 +1072,7 @@ test("resolves scoped npm plugins in config", async () => {
},
})
await Instance.provide({
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
@@ -1099,7 +1110,7 @@ test("merges plugin arrays from global and local configs", async () => {
},
})
await Instance.provide({
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
@@ -1258,7 +1269,7 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
},
})
await Instance.provide({
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
@@ -1307,7 +1318,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
},
})
await Instance.provide({
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const cfg = await load()
@@ -1883,7 +1894,7 @@ test("project config overrides remote well-known config", async () => {
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(Npm.defaultLayer),
Layer.provide(noopNpm),
)
try {
@@ -1941,7 +1952,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(Npm.defaultLayer),
Layer.provide(noopNpm),
)
try {
@@ -2096,7 +2107,7 @@ describe("deduplicatePluginOrigins", () => {
},
})
await Instance.provide({
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
+7 -4
View File
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { provideTestInstance, tmpdir } from "../fixture/fixture"
import { InstanceRuntime } from "@/project/instance-runtime"
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
import { Config } from "@/config/config"
import { Global } from "@opencode-ai/core/global"
@@ -13,7 +13,10 @@ import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd"
import { ConfigPlugin } from "@/config/plugin"
const wintest = process.platform === "win32" ? test : test.skip
const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait)))
const clear = async (wait = false) => {
await AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate()))
if (wait) await InstanceRuntime.disposeAllInstances()
}
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
beforeEach(async () => {
@@ -87,7 +90,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
},
})
await Instance.provide({
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const server = await load()
@@ -3,9 +3,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { $ } from "bun"
import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { InstanceStore } from "../../src/project/instance-store"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(CrossSpawnSpawner.defaultLayer)
@@ -70,7 +69,7 @@ it.live("InstanceState invalidates on reload", () =>
)
const a = yield* access(state, dir)
yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir }))
yield* Effect.promise(() => reloadTestInstance({ directory: dir }))
const b = yield* access(state, dir)
expect(a).not.toBe(b)
@@ -270,7 +269,7 @@ it.live("InstanceState correct after interleaved init and dispose", () =>
const [, b] = yield* Effect.all(
[
Effect.promise(() => InstanceStore.reloadInstance({ directory: one })),
Effect.promise(() => reloadTestInstance({ directory: one })),
Test.use((svc) => svc.get()).pipe(provideInstance(two)),
],
{ concurrency: "unbounded" },
+23
View File
@@ -0,0 +1,23 @@
import { Config } from "@/config/config"
import { emptyConsoleState } from "@/config/console-state"
import { Effect, Layer } from "effect"
export function make(overrides: Partial<Config.Interface> = {}) {
return Config.Service.of({
get: () => Effect.succeed({}),
getGlobal: () => Effect.succeed({}),
getConsoleState: () => Effect.succeed(emptyConsoleState),
update: () => Effect.void,
updateGlobal: (config) => Effect.succeed({ info: config, changed: false }),
invalidate: () => Effect.void,
directories: () => Effect.succeed([]),
waitForDependencies: () => Effect.void,
...overrides,
})
}
export function layer(overrides?: Partial<Config.Interface>) {
return Layer.succeed(Config.Service, make(overrides))
}
export * as TestConfig from "./config"
+33 -14
View File
@@ -1,20 +1,44 @@
import { $ } from "bun"
import * as Observability from "@opencode-ai/core/effect/observability"
import * as fs from "fs/promises"
import os from "os"
import path from "path"
import { Effect, Context } from "effect"
import { Effect, Context, Layer, ManagedRuntime } from "effect"
import type * as PlatformError from "effect/PlatformError"
import type * as Scope from "effect/Scope"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import type { Config } from "@/config/config"
import { InstanceRef } from "../../src/effect/instance-ref"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { InstanceStore } from "../../src/project/instance-store"
import { Instance } from "../../src/project/instance"
import { TestLLMServer } from "../lib/llm-server"
// Re-export for test ergonomics. The implementation lives next to the runtime
// it consumes; see `InstanceStore.disposeAllInstances` for the rationale.
export { disposeAllInstances } from "../../src/project/instance-store"
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const testInstanceRuntime = ManagedRuntime.make(
InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap), Layer.provideMerge(Observability.layer)),
)
const runTestInstanceStore = <A>(fn: (store: InstanceStore.Interface) => Effect.Effect<A>) =>
testInstanceRuntime.runPromise(InstanceStore.Service.use(fn))
export async function provideTestInstance<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) {
const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory, init: input.init }))
try {
return await Instance.restore(ctx, () => input.fn())
} finally {
await runTestInstanceStore((store) => store.dispose(ctx))
}
}
export async function reloadTestInstance(input: { directory: string }) {
return runTestInstanceStore((store) => store.reload(input))
}
export async function disposeAllInstances() {
await Promise.all([InstanceRuntime.disposeAllInstances(), runTestInstanceStore((store) => store.disposeAll())])
}
// Strip null bytes from paths (defensive fix for CI environment issues)
function sanitizePath(p: string): string {
@@ -129,12 +153,10 @@ export const provideInstance =
(directory: string) =>
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
Effect.contextWith((services: Context.Context<R>) =>
Effect.promise<A>(async () =>
Instance.provide({
directory,
fn: () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, Instance.current))),
}),
),
Effect.promise<A>(async () => {
const ctx = await runTestInstanceStore((store) => store.load({ directory }))
return Instance.restore(ctx, () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))))
}),
)
export function provideTmpdirInstance<A, E, R>(
@@ -148,10 +170,7 @@ export function provideTmpdirInstance<A, E, R>(
yield* Effect.addFinalizer(() =>
provided
? Effect.promise(() =>
Instance.provide({
directory: path,
fn: () => InstanceStore.disposeInstance(Instance.current),
}),
runTestInstanceStore((store) => store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx)))),
).pipe(Effect.ignore)
: Effect.void,
)
+2 -2
View File
@@ -1,5 +1,5 @@
import { test, expect, mock, beforeEach } from "bun:test"
import { InstanceStore } from "../../src/project/instance-store"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { Effect } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
@@ -198,7 +198,7 @@ function withInstance(
fn: async () => {
await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer)))
// dispose instance to clean up state between tests
await InstanceStore.disposeInstance(Instance.current)
await InstanceRuntime.disposeInstance(Instance.current)
},
})
}
+11 -5
View File
@@ -6,8 +6,14 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Permission } from "../../src/permission"
import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import {
disposeAllInstances,
provideInstance,
provideTmpdirInstance,
reloadTestInstance,
tmpdirScoped,
} from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { MessageID, SessionID } from "../../src/session/schema"
@@ -1000,7 +1006,7 @@ it.live("pending permission rejects on instance dispose", () =>
expect(yield* waitForPending(1).pipe(run)).toHaveLength(1)
yield* Effect.promise(() =>
Instance.provide({ directory: dir, fn: () => void InstanceStore.disposeInstance(Instance.current) }),
Instance.provide({ directory: dir, fn: () => void InstanceRuntime.disposeInstance(Instance.current) }),
)
const exit = yield* Fiber.await(fiber)
@@ -1024,7 +1030,7 @@ it.live("pending permission rejects on instance reload", () =>
}).pipe(run, Effect.forkScoped)
expect(yield* waitForPending(1).pipe(run)).toHaveLength(1)
yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir }))
yield* Effect.promise(() => reloadTestInstance({ directory: dir }))
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
@@ -1118,7 +1124,7 @@ it.live("ask - abort should clear pending request", () =>
const pending = yield* waitForPending(1).pipe(run)
expect(pending).toHaveLength(1)
yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir }))
yield* Effect.promise(() => reloadTestInstance({ directory: dir }))
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
@@ -1,11 +1,40 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Effect } from "effect"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { pathToFileURL } from "url"
import { Effect, Layer } from "effect"
import { provideTestInstance, tmpdir } from "../fixture/fixture"
import { ProviderAuth } from "@/provider/auth"
import { ProviderID } from "../../src/provider/schema"
import { Plugin } from "@/plugin"
import { Auth } from "@/auth"
import { Bus } from "@/bus"
import { TestConfig } from "../fixture/config"
function layer(directory: string, plugins: string[]) {
return ProviderAuth.layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(
Plugin.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(
TestConfig.layer({
get: () =>
Effect.succeed({
plugin: plugins,
plugin_origins: plugins.map((plugin) => ({
spec: plugin,
source: path.join(directory, "opencode.json"),
scope: "local" as const,
})),
}),
directories: () => Effect.succeed([directory]),
}),
),
),
),
)
}
describe("plugin.auth-override", () => {
test("user plugin overrides built-in github-copilot auth", async () => {
@@ -37,30 +66,32 @@ describe("plugin.auth-override", () => {
await using plain = await tmpdir()
const methods = await Instance.provide({
directory: tmp.path,
fn: async () => {
return Effect.runPromise(
ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)),
)
},
})
const plainMethods = await Instance.provide({
directory: plain.path,
fn: async () => {
return Effect.runPromise(
ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)),
)
},
})
const plugin = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "custom-copilot-auth.ts")).href
const [methods, plainMethods] = await Promise.all([
provideTestInstance({
directory: tmp.path,
fn: async () => {
return Effect.runPromise(
ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(tmp.path, [plugin]))),
)
},
}),
provideTestInstance({
directory: plain.path,
fn: async () => {
return Effect.runPromise(
ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(plain.path, []))),
)
},
}),
])
const copilot = methods[ProviderID.make("github-copilot")]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
}, 30000) // Increased timeout for plugin installation
}, 30000)
})
const file = path.join(import.meta.dir, "../../src/plugin/index.ts")
@@ -1,9 +1,9 @@
import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
import { Filesystem } from "@/util/filesystem"
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
@@ -12,8 +12,9 @@ process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
const { Plugin } = await import("../../src/plugin/index")
const { PluginLoader } = await import("../../src/plugin/loader")
const { readPackageThemes } = await import("../../src/plugin/shared")
const { Instance } = await import("../../src/project/instance")
const { Bus } = await import("../../src/bus")
const { Npm } = await import("@opencode-ai/core/npm")
const { TestConfig } = await import("../fixture/config")
afterAll(() => {
if (disableDefault === undefined) {
@@ -28,14 +29,31 @@ afterEach(async () => {
})
async function load(dir: string) {
return Instance.provide({
directory: dir,
fn: async () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
yield* plugin.list()
}).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise),
})
const source = path.join(dir, "opencode.json")
const config = (await Bun.file(source).json()) as { plugin?: Array<string | [string, Record<string, unknown>]> }
const plugins = config.plugin ?? []
return Effect.gen(function* () {
const plugin = yield* Plugin.Service
yield* plugin.list()
}).pipe(
Effect.provide(
Plugin.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(
TestConfig.layer({
get: () =>
Effect.succeed({
plugin: plugins,
plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })),
}),
directories: () => Effect.succeed([dir]),
}),
),
),
),
provideInstance(dir),
Effect.runPromise,
)
}
describe("plugin.loader.shared", () => {
@@ -0,0 +1,85 @@
import { afterEach, expect, test } from "bun:test"
import { Hono } from "hono"
import { existsSync } from "node:fs"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap"
import { Instance } from "../../src/project/instance"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { InstanceMiddleware } from "../../src/server/routes/instance/middleware"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
// These regressions cover the legacy instance-loading paths fixed by PRs
// #25389 and #25449. The plugin config hook writes a marker file, and the test
// bodies deliberately avoid touching Plugin or config directly. The marker only
// exists if InstanceBootstrap ran at the instance boundary.
afterEach(async () => {
await disposeAllInstances()
})
async function bootstrapFixture() {
return tmpdir({
init: async (dir) => {
const marker = path.join(dir, "config-hook-fired")
const pluginFile = path.join(dir, "plugin.ts")
await Bun.write(
pluginFile,
[
`const MARKER = ${JSON.stringify(marker)}`,
"export default async () => ({",
" config: async () => {",
' await Bun.write(MARKER, "ran")',
" },",
"})",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(pluginFile).href],
}),
)
return marker
},
})
}
test("Instance.provide runs InstanceBootstrap before fn (boundary invariant)", async () => {
await using tmp = await bootstrapFixture()
await Instance.provide({
directory: tmp.path,
fn: async () => "ok",
})
expect(existsSync(tmp.extra)).toBe(true)
})
test("CLI bootstrap runs InstanceBootstrap before callback", async () => {
await using tmp = await bootstrapFixture()
await cliBootstrap(tmp.path, async () => "ok")
expect(existsSync(tmp.extra)).toBe(true)
})
test("legacy Hono instance middleware runs InstanceBootstrap before next handler", async () => {
await using tmp = await bootstrapFixture()
const app = new Hono().use(InstanceMiddleware()).get("/probe", (c) => c.text("ok"))
const response = await app.request("/probe", { headers: { "x-opencode-directory": tmp.path } })
expect(response.status).toBe(200)
expect(existsSync(tmp.extra)).toBe(true)
})
test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => {
await using tmp = await bootstrapFixture()
await InstanceRuntime.reloadInstance({ directory: tmp.path })
expect(existsSync(tmp.extra)).toBe(true)
})
@@ -3,12 +3,17 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Effect, Fiber, Layer } from "effect"
import { InstanceRef } from "../../src/effect/instance-ref"
import { registerDisposer } from "../../src/effect/instance-registry"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer))
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const it = testEffect(
Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(noopBootstrap)),
)
afterEach(async () => {
await disposeAllInstances()
@@ -5,7 +5,7 @@ import path from "path"
import { Cause, Effect, Exit, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { Worktree } from "../../src/worktree"
import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -138,9 +138,10 @@ describe("Worktree", () => {
expect(props.branch).toBe(info.branch)
yield* Effect.promise(() =>
InstanceStore.runtime.runPromise((s) =>
s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)),
),
Instance.provide({
directory: info.directory,
fn: () => InstanceRuntime.disposeInstance(Instance.current),
}),
)
yield* Effect.promise(() => Bun.sleep(100))
yield* svc.remove({ directory: info.directory })
@@ -162,9 +163,10 @@ describe("Worktree", () => {
yield* Effect.promise(() => ready)
yield* Effect.promise(() =>
InstanceStore.runtime.runPromise((s) =>
s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)),
),
Instance.provide({
directory: info.directory,
fn: () => InstanceRuntime.disposeInstance(Instance.current),
}),
)
yield* Effect.promise(() => Bun.sleep(100))
yield* svc.remove({ directory: info.directory })
@@ -1,7 +1,7 @@
import { afterEach, test, expect } from "bun:test"
import { Question } from "../../src/question"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { QuestionID } from "../../src/question/schema"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { SessionID } from "../../src/session/schema"
@@ -422,7 +422,7 @@ test("pending question rejects on instance dispose", async () => {
fn: async () => {
const items = await list()
expect(items).toHaveLength(1)
await InstanceStore.disposeInstance(Instance.current)
await InstanceRuntime.disposeInstance(Instance.current)
},
})
@@ -457,7 +457,7 @@ test("pending question rejects on instance reload", async () => {
fn: async () => {
const items = await list()
expect(items).toHaveLength(1)
await InstanceStore.reloadInstance({ directory: tmp.path })
await InstanceRuntime.reloadInstance({ directory: tmp.path })
},
})
@@ -11,9 +11,8 @@ import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
@@ -42,8 +41,7 @@ const it = testEffect(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
InstanceBootstrap.defaultLayer,
InstanceStore.defaultLayer,
InstanceRuntime.layer,
Project.defaultLayer,
Workspace.defaultLayer,
),
@@ -5,7 +5,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
@@ -59,7 +59,7 @@ function withMcpProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E, R>)
)
yield* Effect.addFinalizer(() =>
Effect.promise(() =>
Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }),
Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }),
).pipe(Effect.ignore),
)
@@ -3,7 +3,7 @@ import { Effect, FileSystem, Layer, Path } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
@@ -91,7 +91,7 @@ function withProviderProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E,
yield* writeProviderAuthPlugin(dir)
yield* Effect.addFinalizer(() =>
Effect.promise(() =>
Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }),
Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }),
).pipe(Effect.ignore),
)
@@ -26,6 +26,7 @@ import { Snapshot } from "../../src/snapshot"
import { ProviderTest } from "../fake/provider"
import { testEffect } from "../lib/effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { TestConfig } from "../fixture/config"
void Log.init({ print: false })
@@ -208,7 +209,7 @@ function layer(result: "continue" | "compact") {
function cfg(compaction?: Config.Info["compaction"]) {
const base = Config.Info.zod.parse({})
return Layer.mock(Config.Service)({
return TestConfig.layer({
get: () => Effect.succeed({ ...base, compaction }),
})
}
@@ -5,8 +5,6 @@ import { FetchHttpClient } from "effect/unstable/http"
import { NodeFileSystem } from "@effect/platform-node"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Config } from "@/config/config"
import { emptyConsoleState } from "@/config/console-state"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instruction } from "../../src/session/instruction"
import type { MessageV2 } from "../../src/session/message-v2"
@@ -14,22 +12,11 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { Global } from "@opencode-ai/core/global"
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestConfig } from "../fixture/config"
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
const configLayer = Layer.succeed(
Config.Service,
Config.Service.of({
get: () => Effect.succeed({}),
getGlobal: () => Effect.succeed({}),
getConsoleState: () => Effect.succeed(emptyConsoleState),
update: () => Effect.void,
updateGlobal: (config) => Effect.succeed(config),
invalidate: () => Effect.void,
directories: () => Effect.succeed([]),
waitForDependencies: () => Effect.void,
}),
)
const configLayer = TestConfig.layer()
const instructionLayer = (global: Partial<Global.Interface>) =>
Instruction.layer.pipe(
+41 -1
View File
@@ -7,10 +7,50 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ToolRegistry } from "@/tool/registry"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestConfig } from "../fixture/config"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Plugin } from "@/plugin"
import { Question } from "@/question"
import { Todo } from "@/session/todo"
import { Skill } from "@/skill"
import { Agent } from "@/agent/agent"
import { Session } from "@/session/session"
import { Provider } from "@/provider/provider"
import { LSP } from "@/lsp/lsp"
import { Instruction } from "@/session/instruction"
import { Bus } from "@/bus"
import { FetchHttpClient } from "effect/unstable/http"
import { Format } from "@/format"
import { Ripgrep } from "@/file/ripgrep"
import * as Truncate from "@/tool/truncate"
import { InstanceState } from "@/effect/instance-state"
const node = CrossSpawnSpawner.defaultLayer
const configLayer = TestConfig.layer({
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
})
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
const registryLayer = ToolRegistry.layer.pipe(
Layer.provide(configLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Question.defaultLayer),
Layer.provide(Todo.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer),
Layer.provide(node),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Truncate.defaultLayer),
)
const it = testEffect(Layer.mergeAll(registryLayer, node))
afterEach(async () => {
await disposeAllInstances()
@@ -9,6 +9,7 @@ import { Filesystem } from "@/util/filesystem"
import path from "path"
import { testEffect } from "../lib/effect"
import { writeFileStringScoped } from "../lib/filesystem"
import { TestConfig } from "../fixture/config"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
const ROOT = path.resolve(import.meta.dir, "..", "..")
@@ -19,7 +20,7 @@ const configuredLayer = (cfg: Config.Info) =>
Layer.mergeAll(
Truncate.defaultLayer,
NodeFileSystem.layer,
Layer.mock(Config.Service)({ get: () => Effect.succeed(cfg) }),
TestConfig.layer({ get: () => Effect.succeed(cfg) }),
)
const configuredIt = (cfg: Config.Info) => testEffect(configuredLayer(cfg))