diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index ec4131a46c..d367f44083 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -128,17 +128,8 @@ See `specs/effect/migration.md` for the compact pattern reference and examples. Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect/migration.md` for the full pattern. -## Instance.bind — ALS for native callbacks +## Callback boundaries -`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called. +Use `EffectBridge` for native or external callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, plugin callbacks, etc.) that need to re-enter Effect services with instance/workspace context. -Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish` or anything that reads `Instance.directory`. - -You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers. - -```typescript -const cb = Instance.bind((err, evts) => { - Bus.publish(MyEvent, { ... }) -}) -nativeAddon.subscribe(dir, cb) -``` +Plain async code should pass explicit context or stay inside an Effect fiber; do not add ambient instance context shims. diff --git a/packages/opencode/specs/effect/facades.md b/packages/opencode/specs/effect/facades.md index 8bf7d97bad..f7e3165f00 100644 --- a/packages/opencode/specs/effect/facades.md +++ b/packages/opencode/specs/effect/facades.md @@ -6,7 +6,6 @@ Current status on this branch: - `src/` has 5 `makeRuntime(...)` call sites total. - 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`. -- 1 is tracked primarily by the instance-context migration rather than facade removal: `src/project/instance.ts`. - That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`. Recent progress: @@ -18,7 +17,6 @@ Recent progress: - `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`. - `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`. -- `src/project/instance.ts` still uses a dedicated runtime for project boot, but that file is really part of the broader legacy instance-context transition tracked in `instance-context.md`. ## Completed Batches @@ -192,7 +190,6 @@ Most of the original facade-removal backlog is already done. The practical remai 1. remove the `Npm` runtime-backed facade from `src/npm/index.ts` 2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts` -3. keep `src/project/instance.ts` in the separate instance-context migration, not this checklist ## Checklist diff --git a/packages/opencode/specs/effect/guide.md b/packages/opencode/specs/effect/guide.md index 5df0293448..e8a1a19c56 100644 --- a/packages/opencode/specs/effect/guide.md +++ b/packages/opencode/specs/effect/guide.md @@ -197,13 +197,9 @@ For background loops, use `Effect.repeat` or `Effect.schedule` with [`EffectBridge`](../../src/effect/bridge.ts) is the sanctioned helper for Promise/callback interop that needs to preserve instance/workspace context. -Keep it, but reduce its dependency on legacy `Instance.current` / -`Instance.restore` over time. - -`Instance.bind` / `Instance.restore` are transitional legacy tools. Use -them only for native callbacks that still require legacy ALS context. Do -not use them for `setTimeout`, `Promise.then`, `EventEmitter.on`, or -Effect fibers. +It preserves explicit `InstanceRef` / `WorkspaceRef` context for effects run +through the bridge. Plain JS callbacks that need instance data should receive +that data explicitly. ## Testing diff --git a/packages/opencode/specs/effect/instance-context.md b/packages/opencode/specs/effect/instance-context.md index 6d63715030..94564004c9 100644 --- a/packages/opencode/specs/effect/instance-context.md +++ b/packages/opencode/specs/effect/instance-context.md @@ -1,309 +1,13 @@ -# Instance context migration +# Instance Context -Practical plan for retiring the promise-backed / ALS-backed `Instance` helper in `src/project/instance.ts` and moving instance selection fully into Effect-provided scope. +Instance selection is now Effect-provided context. -## Goal +Use these APIs: -End state: +- `InstanceRef` for the current project context. +- `WorkspaceRef` for the current workspace id. +- `InstanceState.context` / `InstanceState.directory` inside Effect services that require an instance. +- `InstanceStore` at entry boundaries that need to load, reload, or dispose project contexts. +- `EffectBridge` for native, plugin, or plain JavaScript callback boundaries that need to re-enter Effect with captured refs. -- request, CLI, TUI, and tool entrypoints shift into an instance through Effect, not `Instance.provide(...)` -- Effect code reads the current instance from `InstanceRef` or its eventual replacement, not from ALS-backed sync getters -- per-directory boot, caching, and disposal are scoped Effect resources, not a module-level `Map>` -- ALS remains only as a temporary bridge for native callback APIs that fire outside the Effect fiber tree - -## Current split - -Today `src/project/instance.ts` still owns two separate concerns: - -- ambient current-instance context through `LocalContext` / `AsyncLocalStorage` -- per-directory boot and deduplication through `cache: Map>` - -At the same time, the Effect side already exists: - -- `src/effect/instance-ref.ts` provides `InstanceRef` and `WorkspaceRef` -- `src/effect/run-service.ts` already attaches those refs when a runtime starts inside an active instance ALS context -- `src/effect/instance-state.ts` already prefers `InstanceRef` and only falls back to ALS when needed - -That means the migration is not "invent instance context in Effect". The migration is "stop relying on the legacy helper as the primary source of truth". - -## End state shape - -Near-term target shape: - -```ts -InstanceScope.with({ directory, workspaceID }, effect) -``` - -Responsibilities of `InstanceScope.with(...)`: - -- resolve `directory`, `project`, and `worktree` -- acquire or reuse the scoped per-directory instance environment -- provide `InstanceRef` and `WorkspaceRef` -- run the caller's Effect inside that environment - -Code inside the boundary should then do one of these: - -```ts -const ctx = yield * InstanceState.context -const dir = yield * InstanceState.directory -``` - -Long-term, once `InstanceState` itself is replaced by keyed layers / `LayerMap`, those reads can move to an `InstanceContext` service without changing the outer migration order. - -## Migration phases - -### Phase 1: stop expanding the legacy surface - -Rules for all new code: - -- do not add new `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current` reads inside Effect code -- do not add new `Instance.provide(...)` boundaries unless there is no Effect-native seam yet -- use `InstanceState.context`, `InstanceState.directory`, or an explicit `ctx` parameter inside Effect code - -Success condition: - -- the file inventory below only shrinks from here - -### Phase 2: remove direct sync getter reads from Effect services - -Convert Effect services first, before replacing the top-level boundary. These modules already run inside Effect and mostly need `yield* InstanceState.context` or a yielded `ctx` instead of ambient sync access. - -Primary batch, highest payoff: - -- `src/file/index.ts` -- `src/lsp/server.ts` -- `src/worktree/index.ts` -- `src/file/watcher.ts` -- `src/format/formatter.ts` -- `src/session/index.ts` -- `src/project/vcs.ts` - -Mechanical replacement rule: - -- `Instance.directory` -> `ctx.directory` or `yield* InstanceState.directory` -- `Instance.worktree` -> `ctx.worktree` -- `Instance.project` -> `ctx.project` - -Do not thread strings manually through every public method if the service already has access to Effect context. - -### Phase 3: convert entry boundaries to provide instance refs directly - -After the service bodies stop assuming ALS, move the top-level boundaries to shift into Effect explicitly. - -Main boundaries: - -- HTTP server middleware and experimental `HttpApi` entrypoints -- CLI commands -- TUI worker / attach / thread entrypoints -- tool execution entrypoints - -These boundaries should become Effect-native wrappers that: - -- decode directory / workspace inputs -- resolve the instance context once -- provide `InstanceRef` and `WorkspaceRef` -- run the requested Effect - -At that point `Instance.provide(...)` becomes a legacy adapter instead of the normal code path. - -### Phase 4: replace promise boot cache with scoped instance runtime - -Once boundaries and services both rely on Effect context, replace the module-level promise cache in `src/project/instance.ts`. - -Target replacement: - -- keyed scoped runtime or keyed layer acquisition for each directory -- reuse via `ScopedCache`, `LayerMap`, or another keyed Effect resource manager -- cleanup performed by scope finalizers instead of `disposeAll()` iterating a Promise map - -This phase should absorb the current responsibilities of: - -- `cache` in `src/project/instance.ts` -- `boot(...)` -- most of `disposeInstance(...)` -- manual `reload(...)` / `disposeAll()` fan-out logic - -### Phase 5: shrink ALS to callback bridges only - -Keep ALS only where a library invokes callbacks outside the Effect fiber tree and we still need to call code that reads instance context synchronously. - -Known bridge cases today: - -- `src/file/watcher.ts` -- `src/session/llm.ts` -- some LSP and plugin callback paths - -If those libraries become fully wrapped in Effect services, the remaining `Instance.bind(...)` uses can disappear too. - -### Phase 6: delete the legacy sync API - -Only after earlier phases land: - -- remove broad use of `Instance.current`, `Instance.directory`, `Instance.worktree`, `Instance.project` -- reduce `src/project/instance.ts` to a thin compatibility shim or delete it entirely -- remove the ALS fallback from `InstanceState.context` - -## Inventory of direct legacy usage - -Direct legacy usage means any source file that still calls one of: - -- `Instance.current` -- `Instance.directory` -- `Instance.worktree` -- `Instance.project` -- `Instance.provide(...)` -- `Instance.bind(...)` -- `Instance.restore(...)` -- `Instance.reload(...)` -- `Instance.dispose()` / `Instance.disposeAll()` - -Current total: `56` files in `packages/opencode/src`. - -### Core bridge and plumbing - -These files define or adapt the current bridge. They should change last, after callers have moved. - -- `src/project/instance.ts` -- `src/effect/run-service.ts` -- `src/effect/instance-state.ts` -- `src/project/bootstrap.ts` -- `src/config/config.ts` - -Migration rule: - -- keep these as compatibility glue until the outer boundaries and inner services stop depending on ALS - -### HTTP and server boundaries - -These are the current request-entry seams that still create or consume instance context through the legacy helper. - -- `src/server/routes/instance/middleware.ts` -- `src/server/routes/instance/index.ts` -- `src/server/routes/instance/project.ts` -- `src/server/routes/control/workspace.ts` -- `src/server/routes/instance/file.ts` -- `src/server/routes/instance/experimental.ts` -- `src/server/routes/global.ts` - -Migration rule: - -- move these to explicit Effect entrypoints that provide `InstanceRef` / `WorkspaceRef` -- do not move these first; first reduce the number of downstream handlers and services that still expect ambient ALS - -### CLI and TUI boundaries - -These commands still enter an instance through `Instance.provide(...)` or read sync getters directly. - -- `src/cli/bootstrap.ts` -- `src/cli/cmd/agent.ts` -- `src/cli/cmd/debug/agent.ts` -- `src/cli/cmd/debug/ripgrep.ts` -- `src/cli/cmd/github.ts` -- `src/cli/cmd/import.ts` -- `src/cli/cmd/mcp.ts` -- `src/cli/cmd/models.ts` -- `src/cli/cmd/plug.ts` -- `src/cli/cmd/pr.ts` -- `src/cli/cmd/providers.ts` -- `src/cli/cmd/stats.ts` -- `src/cli/cmd/tui/attach.ts` -- `src/cli/cmd/tui/plugin/runtime.ts` -- `src/cli/cmd/tui/thread.ts` -- `src/cli/cmd/tui/worker.ts` - -Migration rule: - -- converge these on one shared `withInstance(...)` Effect entry helper instead of open-coded `Instance.provide(...)` -- after that helper is proven, inline the legacy implementation behind an Effect-native scope provider - -### Tool boundary code - -These tools mostly use direct getters for path resolution and repo-relative display logic. - -- `src/tool/apply_patch.ts` -- `src/tool/bash.ts` -- `src/tool/edit.ts` -- `src/tool/lsp.ts` -- `src/tool/plan.ts` -- `src/tool/read.ts` -- `src/tool/write.ts` - -Migration rule: - -- expose the current instance as an explicit Effect dependency for tool execution -- keep path logic local; avoid introducing another global singleton for tool state - -### Effect services still reading ambient instance state - -These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper. - -- `src/agent/agent.ts` -- `src/cli/cmd/tui/config/tui-migrate.ts` -- `src/file/index.ts` -- `src/file/watcher.ts` -- `src/format/formatter.ts` -- `src/lsp/client.ts` -- `src/lsp/index.ts` -- `src/lsp/server.ts` -- `src/mcp/index.ts` -- `src/project/vcs.ts` -- `src/provider/provider.ts` -- `src/pty/index.ts` -- `src/session/session.ts` -- `src/session/instruction.ts` -- `src/session/llm.ts` -- `src/session/system.ts` -- `src/sync/index.ts` -- `src/worktree/index.ts` - -Migration rule: - -- replace direct getter reads with `yield* InstanceState.context` or a yielded `ctx` -- isolate `Instance.bind(...)` callers and convert only the truly callback-driven edges to bridge mode - -### Highest-churn hotspots - -Current highest direct-usage counts by file: - -- `src/file/index.ts` - `18` -- `src/lsp/server.ts` - `14` -- `src/worktree/index.ts` - `12` -- `src/file/watcher.ts` - `9` -- `src/cli/cmd/mcp.ts` - `8` -- `src/format/formatter.ts` - `8` -- `src/tool/apply_patch.ts` - `8` -- `src/cli/cmd/github.ts` - `7` - -These files should drive the first measurable burn-down. - -## Recommended implementation order - -1. Migrate direct getter reads inside Effect services, starting with `file`, `lsp`, `worktree`, `format`, and `session`. -2. Add one shared Effect-native boundary helper for CLI / tool / HTTP entrypoints so we stop open-coding `Instance.provide(...)`. -3. Move experimental `HttpApi` entrypoints to that helper so the new server stack proves the pattern. -4. Convert remaining CLI and tool boundaries. -5. Replace the promise cache with a keyed scoped runtime or keyed layer map. -6. Delete ALS fallback paths once only callback bridges still depend on them. - -## Definition of done - -This migration is done when all of the following are true: - -- new requests and commands enter an instance by providing Effect context, not ALS -- Effect services no longer read `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current` -- `Instance.provide(...)` is gone from normal request / CLI / tool execution -- per-directory boot and disposal are handled by scoped Effect resources -- `Instance.bind(...)` is either gone or confined to a tiny set of native callback adapters - -## Tracker and worktree - -Active tracker items: - -- `lh7l73` - overall `HttpApi` migration -- `yobwlk` - remove direct `Instance.*` reads inside Effect services -- `7irl1e` - replace `InstanceState` / legacy instance caching with keyed Effect layers - -Dedicated worktree for this transition: - -- path: `/Users/kit/code/open-source/opencode-worktrees/instance-effect-shift` -- branch: `kit/instance-effect-shift` +Do not add new ambient instance globals. Promise and callback boundaries should either stay in Effect, use `EffectBridge`, or pass the required context explicitly. diff --git a/packages/opencode/specs/effect/loose-ends.md b/packages/opencode/specs/effect/loose-ends.md index 4e7ada7ff9..d30efd1815 100644 --- a/packages/opencode/specs/effect/loose-ends.md +++ b/packages/opencode/specs/effect/loose-ends.md @@ -24,10 +24,6 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc - [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists. - [ ] `cli/cmd/tui/config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands. -## Instance cleanup - -- [ ] `project/instance.ts` - keep shrinking the legacy ALS / Promise cache after the remaining `Instance.*` callers move over. - ## Notes - Prefer small, semantics-preserving config migrations. Config precedence, legacy key migration, and plugin origin tracking are easy to break accidentally. diff --git a/packages/opencode/specs/effect/todo.md b/packages/opencode/specs/effect/todo.md index 092e80b767..acb4a995c8 100644 --- a/packages/opencode/specs/effect/todo.md +++ b/packages/opencode/specs/effect/todo.md @@ -64,13 +64,11 @@ P6 OA explicit and testable instead of mutable module state. Shrinks: [`global.ts`](../../../core/src/global.ts) import-time side effects, mutable `Global.Path` overrides, and its `Flag` dependency. -- `INST` Instance shim — remove ambient `Instance` usage and old ALS - access patterns. - Shrinks: [`src/project/instance.ts`](../../src/project/instance.ts). +- `INST` Instance context — keep project context explicit through Effect refs + and bridge boundaries. - `BRIDGE` Promise/callback interop — keep bridge helpers, but reduce legacy ALS coupling. - Shrinks: [`src/effect/bridge.ts`](../../src/effect/bridge.ts) - dependency on [`project/instance.ts`](../../src/project/instance.ts). + Shrinks: ad hoc Promise/callback re-entry code. - `PROC` AppProcess migration — prefer `AppProcess.Service` over raw process wrappers. Shrinks: direct spawn callsites and legacy process helpers. @@ -221,74 +219,13 @@ Next PR candidates: ## P4: Instance And Bridge -[`project/instance.ts`](../../src/project/instance.ts) is the deletion -target. [`effect/bridge.ts`](../../src/effect/bridge.ts) is not a near-term -deletion target; Promise/callback interop will continue to exist. +Instance context migration is complete for the legacy sync shim. Promise and callback interop continues through [`effect/bridge.ts`](../../src/effect/bridge.ts). -Goal: +Current rules: -- Keep a sanctioned bridge for Promise/callback boundaries. -- Reduce bridge dependence on legacy `Instance.restore` / `Instance.current`. -- Move callers toward `InstanceRef`, `WorkspaceRef`, `InstanceState`, or - explicit context where practical. -- Delete `project/instance.ts` only after ambient Instance coupling is gone. - -Important distinction: - -- `InstanceState.context`, `InstanceState.directory`, and - `InstanceState.workspaceID` are acceptable inside normal Effect service - code when `InstanceRef` / `WorkspaceRef` are provided by the runtime. -- The deletion blockers are the fallback and callback paths that rely on - ambient ALS: direct `Instance.*` reads, `InstanceState.bind(...)`, - `AppRuntime.runPromise(...)` re-entry from plain JS, and bridge restore - code that installs legacy ALS before invoking callbacks. - -Current bottom-up inventory from `dev`: - -- Direct `Instance.*` value readers: - [`tool/repo_overview.ts`](../../src/tool/repo_overview.ts), - [`control-plane/adapters/worktree.ts`](../../src/control-plane/adapters/worktree.ts), - [`cli/bootstrap.ts`](../../src/cli/bootstrap.ts). -- `InstanceState.bind(...)` callback boundaries: - [`file/watcher.ts`](../../src/file/watcher.ts) native watcher callback, - [`storage/db.ts`](../../src/storage/db.ts) transaction/effect callbacks, - [`session/llm.ts`](../../src/session/llm.ts) workflow approval callback. -- `AppRuntime.runPromise(...)` / re-entry from plain JS: - [`project/with-instance.ts`](../../src/project/with-instance.ts), - [`project/instance-runtime.ts`](../../src/project/instance-runtime.ts), - [`control-plane/adapters/worktree.ts`](../../src/control-plane/adapters/worktree.ts), - [`cli/effect-cmd.ts`](../../src/cli/effect-cmd.ts), plus global/non-instance - callsites such as CLI upgrade and ACP agent defaults. -- Intentional bridge users to classify, not delete blindly: - workspace adapters in [`control-plane/workspace.ts`](../../src/control-plane/workspace.ts), - MCP, command execution, plugins, pty lifecycle, bus scope cleanup, task - cancellation, and HTTP lifecycle reload/dispose paths. -- Core fallback layer to shrink last: - [`effect/run-service.ts`](../../src/effect/run-service.ts), - [`effect/bridge.ts`](../../src/effect/bridge.ts), and - [`effect/instance-state.ts`](../../src/effect/instance-state.ts). - -Recommended PR order: - -- [ ] `INST-1` Remove direct `Instance.*` value readers. Start with - `repo_overview`, `worktree` adapter, and `cli/bootstrap`; pass context - explicitly or obtain it from an Effect boundary. -- [ ] `INST-2` Move type-only `InstanceContext` imports from - [`project/instance.ts`](../../src/project/instance.ts) to - [`project/instance-context.ts`](../../src/project/instance-context.ts). -- [ ] `INST-3` Audit each `InstanceState.bind(...)` callback from the inside - out: list what the callback calls (`Bus.publish`, database effects, - permission/session services), then replace ambient capture with explicit - `InstanceRef` / `WorkspaceRef` provision or an `EffectBridge` call. -- [ ] `INST-4` Classify `AppRuntime.runPromise(...)` callsites as global, - instance-scoped with explicit refs, or bridge-required. Eliminate the - instance-scoped callsites that rely on `run-service.attach()` falling - back to `Instance.current`. -- [ ] `INST-5` After consumers are explicit, remove `Instance.current` fallback - from `InstanceState.context` and `run-service.attach()`. -- [ ] `INST-6` Move any remaining `restore` / `bind` compatibility helpers to - the boundary that still needs them, then delete - [`project/instance.ts`](../../src/project/instance.ts). +- Effect services read instance data from `InstanceRef`, `WorkspaceRef`, `InstanceState`, or explicit arguments. +- Plain JavaScript callback boundaries use `EffectBridge` or explicit context arguments. +- Runtime entrypoints must provide refs explicitly when they are instance-scoped. ## Lower Priority Tracks diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index aa123d5991..8cc1750ca0 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -43,16 +43,25 @@ import { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { Agent as AgentModule } from "../agent/agent" import { AppRuntime } from "@/effect/app-runtime" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceRuntime } from "@/project/instance-runtime" import { Installation } from "@/installation" import { MessageV2 } from "@/session/message-v2" import { Config } from "@/config/config" import { ConfigMCP } from "@/config/mcp" import { Todo } from "@/session/todo" -import { Result, Schema } from "effect" +import { Effect, Result, Schema } from "effect" import { LoadAPIKeyError } from "ai" import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" import { InstallationVersion } from "@opencode-ai/core/installation/version" + +const defaultAgentInfo = async (directory: string) => { + const ctx = await InstanceRuntime.load({ directory }) + return AppRuntime.runPromise( + AgentModule.Service.use((svc) => svc.defaultInfo()).pipe(Effect.provideService(InstanceRef, ctx)), + ) +} import { ShellID } from "@/tool/shell/id" type ModeOption = { id: string; name: string; description?: string } @@ -1094,7 +1103,7 @@ export class Agent implements ACPAgent { const currentModeId = await (async () => { if (!availableModes.length) return undefined - const defaultAgent = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo())) + const defaultAgent = await defaultAgentInfo(directory) const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id this.sessionManager.setMode(sessionId, resolvedModeId) return resolvedModeId @@ -1328,8 +1337,7 @@ export class Agent implements ACPAgent { if (!current) { this.sessionManager.setModel(session.id, model) } - const agent = - session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name + const agent = session.modeId ?? (await defaultAgentInfo(directory)).name const parts: Array< | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index ada5f8677d..c96bc99ac4 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -3,7 +3,6 @@ import { Effect, Schema } from "effect" import { AppRuntime, type AppServices } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" import { InstanceRef } from "@/effect/instance-ref" -import { Instance } from "@/project/instance" import { cmd, type WithDoubleDash } from "./cmd/cmd" /** @@ -83,19 +82,11 @@ export const effectCmd = (opts: EffectCmdOpts) => return } const directory = opts.directory?.(args) ?? process.cwd() - // Two-phase: load ctx, then run body inside Instance.current ALS. - // Effect's InstanceRef is provided via fiber context, but that context is - // lost across `await` inside `Effect.promise(async () => ...)` callbacks - // — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())` - // there, attach() falls back to Instance.current ALS, which Node preserves - // across awaits. Matches the pre-effectCmd `bootstrap()` behavior. const { store, ctx } = await AppRuntime.runPromise( InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))), ) try { - await Instance.restore(ctx, () => - AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))), - ) + await AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))) } finally { await AppRuntime.runPromise(store.dispose(ctx)) } diff --git a/packages/opencode/src/control-plane/adapters/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts index 81d9990e7c..b85ee7ae25 100644 --- a/packages/opencode/src/control-plane/adapters/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -1,7 +1,6 @@ import { Effect, Schema } from "effect" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" -import { WorkspaceContext } from "../workspace-context" -import { type WorkspaceAdapter, WorkspaceInfo } from "../types" +import { type WorkspaceAdapter, type WorkspaceAdapterContext, WorkspaceInfo } from "../types" const WorktreeConfig = Schema.Struct({ name: WorkspaceInfo.fields.name, @@ -11,26 +10,31 @@ const WorktreeConfig = Schema.Struct({ const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig) async function loadWorktree() { - const [{ AppRuntime }, { Instance }, { Worktree }] = await Promise.all([ + const [{ AppRuntime }, { Worktree }] = await Promise.all([ import("@/effect/app-runtime"), - import("@/project/instance"), import("@/worktree"), ]) - return { AppRuntime, Instance, Worktree } + return { AppRuntime, Worktree } } +function requireInstance(context: WorkspaceAdapterContext | undefined) { + if (!context?.instance) throw new Error("Worktree adapter requires an instance context") + return context.instance +} + +const provideContext = (effect: Effect.Effect, context: WorkspaceAdapterContext | undefined) => + effect.pipe( + Effect.provideService(InstanceRef, requireInstance(context)), + Effect.provideService(WorkspaceRef, context?.workspaceID), + ) + export const WorktreeAdapter: WorkspaceAdapter = { name: "Worktree", description: "Create a git worktree", - async configure(info) { - const { AppRuntime, Instance, Worktree } = await loadWorktree() - const ctx = Instance.current - const workspaceID = WorkspaceContext.workspaceID + async configure(info, context) { + const { AppRuntime, Worktree } = await loadWorktree() const next = await AppRuntime.runPromise( - Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })).pipe( - Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, workspaceID), - ), + provideContext(Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })), context), ) return { ...info, @@ -38,32 +42,27 @@ export const WorktreeAdapter: WorkspaceAdapter = { directory: next.directory, } }, - async create(info) { - const { AppRuntime, Instance, Worktree } = await loadWorktree() - const ctx = Instance.current - const workspaceID = WorkspaceContext.workspaceID + async create(info, _env, _from, context) { + const { AppRuntime, Worktree } = await loadWorktree() const config = decodeWorktreeConfig(info) await AppRuntime.runPromise( - Worktree.Service.use((svc) => - svc.createFromInfo({ - name: config.name, - directory: config.directory, - ...(config.branch ? { branch: config.branch } : {}), - }), - ).pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID)), + provideContext( + Worktree.Service.use((svc) => + svc.createFromInfo({ + name: config.name, + directory: config.directory, + ...(config.branch ? { branch: config.branch } : {}), + }), + ), + context, + ), ) }, - async list() { - const { AppRuntime, Instance, Worktree } = await loadWorktree() - const ctx = Instance.current - const workspaceID = WorkspaceContext.workspaceID + async list(context) { + const { AppRuntime, Worktree } = await loadWorktree() + const ctx = requireInstance(context) return ( - await AppRuntime.runPromise( - Worktree.Service.use((svc) => svc.list()).pipe( - Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, workspaceID), - ), - ) + await AppRuntime.runPromise(provideContext(Worktree.Service.use((svc) => svc.list()), context)) ).map((info) => ({ type: "worktree", name: info.name, @@ -72,16 +71,11 @@ export const WorktreeAdapter: WorkspaceAdapter = { projectID: ctx.project.id, })) }, - async remove(info) { - const { AppRuntime, Instance, Worktree } = await loadWorktree() - const ctx = Instance.current - const workspaceID = WorkspaceContext.workspaceID + async remove(info, context) { + const { AppRuntime, Worktree } = await loadWorktree() const config = decodeWorktreeConfig(info) await AppRuntime.runPromise( - Worktree.Service.use((svc) => svc.remove({ directory: config.directory })).pipe( - Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, workspaceID), - ), + provideContext(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })), context), ) }, target(info) { diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index e55ae2194e..daa8374530 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,5 +1,6 @@ import { Schema, Struct } from "effect" import { ProjectID } from "@/project/schema" +import type { InstanceContext } from "@/project/instance-context" import { WorkspaceID } from "./schema" import type { DeepMutable } from "@opencode-ai/core/schema" @@ -37,12 +38,22 @@ export type Target = headers?: HeadersInit } +export type WorkspaceAdapterContext = { + readonly instance?: InstanceContext + readonly workspaceID?: WorkspaceID +} + export type WorkspaceAdapter = { name: string description: string - configure(info: WorkspaceInfo): WorkspaceInfo | Promise - create(info: WorkspaceInfo, env: Record, from?: WorkspaceInfo): Promise - list?(): WorkspaceListedInfo[] | Promise - remove(info: WorkspaceInfo): Promise - target(info: WorkspaceInfo): Target | Promise + configure(info: WorkspaceInfo, context?: WorkspaceAdapterContext): WorkspaceInfo | Promise + create( + info: WorkspaceInfo, + env: Record, + from?: WorkspaceInfo, + context?: WorkspaceAdapterContext, + ): Promise + list?(context?: WorkspaceAdapterContext): WorkspaceListedInfo[] | Promise + remove(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Promise + target(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Target | Promise } diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 5b7f867ca9..7bbe4aa325 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -26,8 +26,8 @@ import { SessionID } from "@/session/schema" import { NotFoundError } from "@/storage/storage" import { errorData } from "@/util/error" import { waitEvent } from "./util" -import { WorkspaceContext } from "./workspace-context" import { EffectBridge } from "@/effect/bridge" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { Vcs } from "@/project/vcs" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" @@ -196,6 +196,50 @@ export const layer = Layer.effect( }) } + const adapterContext = Effect.gen(function* () { + return { + instance: yield* InstanceRef, + workspaceID: yield* WorkspaceRef, + } + }) + + const adapterTarget = (workspace: Info) => + Effect.gen(function* () { + const adapter = getAdapter(workspace.projectID, workspace.type) + const context = yield* adapterContext + return yield* EffectBridge.fromPromise(() => adapter.target(workspace, context)) + }) + + const adapterConfigure = (adapter: ReturnType, info: WorkspaceInfo) => + Effect.gen(function* () { + const context = yield* adapterContext + return yield* EffectBridge.fromPromise(() => adapter.configure(info, context)) + }) + + const adapterCreate = ( + adapter: ReturnType, + info: WorkspaceInfo, + env: Record, + from?: WorkspaceInfo, + ) => + Effect.gen(function* () { + const context = yield* adapterContext + return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, context)) + }) + + const adapterList = (adapter: ReturnType) => + Effect.gen(function* () { + const context = yield* adapterContext + return yield* EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.(context) ?? [])) + }) + + const adapterRemove = (info: Info, type: string) => + Effect.gen(function* () { + const adapter = getAdapter(info.projectID, type) + const context = yield* adapterContext + return yield* EffectBridge.fromPromise(() => adapter.remove(info, context)) + }) + const connectSSE = Effect.fn("Workspace.connectSSE")(function* ( url: URL | string, headers: HeadersInit | undefined, @@ -281,8 +325,7 @@ export const layer = Layer.effect( const workspace = yield* get(input.workspaceID) if (!workspace) return input.fallback - const adapter = getAdapter(workspace.projectID, workspace.type) - const target = yield* EffectBridge.fromPromise(() => adapter.target(workspace)) + const target = yield* adapterTarget(workspace) if (target.type === "local") { const store = yield* InstanceStore.Service @@ -375,35 +418,27 @@ export const layer = Layer.effect( events: events.length, }) - yield* Effect.promise(async () => { - await WorkspaceContext.provide({ - workspaceID: space.id, - async fn() { - await Effect.runPromise( - Effect.forEach( - events, - (event) => - sync.replay( - { - id: event.id, - aggregateID: event.aggregate_id, - seq: event.seq, - type: event.type, - data: event.data, - }, - { publish: true }, - ), - { discard: true }, - ), + yield* Effect.forEach( + events, + (event) => + sync + .replay( + { + id: event.id, + aggregateID: event.aggregate_id, + seq: event.seq, + type: event.type, + data: event.data, + }, + { publish: true }, ) - }, - }) - }) + .pipe(Effect.provideService(WorkspaceRef, space.id)), + { discard: true }, + ) }) const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) { - const adapter = getAdapter(space.projectID, space.type) - const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) + const target = yield* adapterTarget(space) if (target.type === "local") return @@ -486,8 +521,7 @@ export const layer = Layer.effect( const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) { if (!flags.experimentalWorkspaces) return - const adapter = getAdapter(space.projectID, space.type) - const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe( + const target = yield* adapterTarget(space).pipe( Effect.catch((error) => Effect.sync(() => { setStatus(space.id, "error") @@ -538,15 +572,13 @@ export const layer = Layer.effect( const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { const id = WorkspaceID.ascending(input.id) const adapter = getAdapter(input.projectID, input.type) - const config = yield* EffectBridge.fromPromise(() => - adapter.configure({ - ...input, - id, - name: Slug.create(), - directory: null, - extra: input.extra ?? null, - }), - ) + const config = yield* adapterConfigure(adapter, { + ...input, + id, + name: Slug.create(), + directory: null, + extra: input.extra ?? null, + }) const info: Info = { id, @@ -583,7 +615,7 @@ export const layer = Layer.effect( OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, } - yield* EffectBridge.fromPromise(() => adapter.create(config, env)) + yield* adapterCreate(adapter, config, env) yield* Effect.all( [ waitEvent({ @@ -622,8 +654,7 @@ export const layer = Layer.effect( if (current?.workspaceID) { const previous = yield* get(current.workspaceID) if (previous) { - const adapter = getAdapter(previous.projectID, previous.type) - const target = yield* EffectBridge.fromPromise(() => adapter.target(previous)) + const target = yield* adapterTarget(previous) if (target.type === "remote") { yield* syncHistory(previous, target.url, target.headers).pipe( @@ -701,8 +732,7 @@ export const layer = Layer.effect( workspaceID, }) - const adapter = getAdapter(space.projectID, space.type) - const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) + const target = yield* adapterTarget(space) if (target.type === "local") { yield* sync.run(Session.Event.Updated, { @@ -856,7 +886,7 @@ export const layer = Layer.effect( registeredAdapters(project.id), ([type, adapter]) => adapter.list - ? EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.() ?? [])).pipe( + ? adapterList(adapter).pipe( Effect.catchCause((error) => Effect.sync(() => { log.warn("workspace adapter list failed", { type, error }) @@ -937,8 +967,7 @@ export const layer = Layer.effect( const info = fromRow(row) yield* Effect.catchCause( Effect.gen(function* () { - const adapter = getAdapter(info.projectID, row.type) - yield* EffectBridge.fromPromise(() => adapter.remove(info)) + yield* adapterRemove(info, row.type) }), () => Effect.sync(() => { diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts index a0f2c224d0..590fb2d7ef 100644 --- a/packages/opencode/src/effect/bridge.ts +++ b/packages/opencode/src/effect/bridge.ts @@ -1,9 +1,6 @@ import { Context, Effect, Exit, Fiber } from "effect" import { WorkspaceContext } from "@/control-plane/workspace-context" -import { Instance } from "@/project/instance" -import type { InstanceContext } from "@/project/instance-context" import type { WorkspaceID } from "@/control-plane/schema" -import { LocalContext } from "@/util/local-context" import { InstanceRef, WorkspaceRef } from "./instance-ref" import { attachWith } from "./run-service" @@ -14,27 +11,14 @@ export interface Shape { readonly bind: (fn: (...args: Args) => Result) => (...args: Args) => Result } -function restore(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R { - if (instance && workspace !== undefined) { - return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn)) - } - if (instance) return Instance.restore(instance, fn) +function restoreWorkspace(workspace: WorkspaceID | undefined, fn: () => R): R { if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn) return fn() } function captureSync() { const fiber = Fiber.getCurrent() - const value = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined - const instance = - value ?? - (() => { - try { - return Instance.current - } catch (err) { - if (!(err instanceof LocalContext.NotFound)) throw err - } - })() + const instance = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined const workspace = (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined) ?? WorkspaceContext.workspaceID return { instance, workspace } @@ -42,47 +26,43 @@ function captureSync() { export const bind = (fn: (...args: Args) => Result) => { const captured = captureSync() - return (...args: Args) => restore(captured.instance, captured.workspace, () => fn(...args)) + return (...args: Args) => + restoreWorkspace(captured.workspace, () => + Effect.runSync(attachWith(Effect.sync(() => fn(...args)), captured)), + ) } /** - * Bridge from Effect into a Promise-returning JS callback while installing - * legacy `Instance.context` and `WorkspaceContext` AsyncLocalStorage for - * the duration of the callback. Effect's `InstanceRef`/`WorkspaceRef` do - * not propagate across async/await boundaries inside `Effect.promise(() => - * async fn)` callbacks that re-enter Effect via `AppRuntime.runPromise`, - * but Node's AsyncLocalStorage does. Use this whenever an Effect crosses - * into JS that may itself spawn new Effect runtimes (workspace adapters, - * legacy plugins, etc.). + * Bridge from Effect into a Promise-returning JS callback while preserving + * `WorkspaceContext` AsyncLocalStorage for callback code that still reads it. + * `InstanceRef` is captured for effects run through the returned bridge APIs; + * plain JS callbacks that need it should receive the ref explicitly. * - * Mirrors `Effect.promise` but restores legacy ALS first. + * Mirrors `Effect.promise` but restores workspace ALS first. */ export const fromPromise = (fn: () => Promise | T): Effect.Effect => Effect.gen(function* () { - const instance = yield* InstanceRef const workspace = yield* WorkspaceRef - return yield* Effect.promise(() => Promise.resolve(restore(instance, workspace, () => fn()))) + return yield* Effect.promise(() => Promise.resolve(restoreWorkspace(workspace, () => fn()))) }) export function make(): Effect.Effect { return Effect.gen(function* () { const ctx = yield* Effect.context() - const value = yield* InstanceRef const captured = captureSync() - const instance = value ?? captured.instance + const instance = (yield* InstanceRef) ?? captured.instance const workspace = (yield* WorkspaceRef) ?? captured.workspace - const attach = (effect: Effect.Effect) => attachWith(effect, { instance, workspace }) const wrap = (effect: Effect.Effect) => - attach(effect).pipe(Effect.provide(ctx)) as Effect.Effect + attachWith(effect.pipe(Effect.provide(ctx)) as Effect.Effect, { instance, workspace }) return { promise: (effect: Effect.Effect) => - restore(instance, workspace, () => Effect.runPromise(wrap(effect))), + restoreWorkspace(workspace, () => Effect.runPromise(wrap(effect))), fork: (effect: Effect.Effect) => - restore(instance, workspace, () => Effect.runFork(wrap(effect))), + restoreWorkspace(workspace, () => Effect.runFork(wrap(effect))), run: (effect: Effect.Effect) => Effect.callback((resume) => { - restore(instance, workspace, () => + restoreWorkspace(workspace, () => Effect.runPromiseExit(wrap(effect)).then((exit) => resume(Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause)), ), @@ -91,7 +71,7 @@ export function make(): Effect.Effect { bind: (fn: (...args: Args) => Result) => (...args: Args) => - restore(instance, workspace, () => fn(...args)), + restoreWorkspace(workspace, () => Effect.runSync(wrap(Effect.sync(() => fn(...args))))), } satisfies Shape }) } diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts index 5c95e01282..1c299cda55 100644 --- a/packages/opencode/src/effect/instance-state.ts +++ b/packages/opencode/src/effect/instance-state.ts @@ -1,8 +1,6 @@ -import { Effect, Fiber, ScopedCache, Scope, Context } from "effect" +import { Effect, ScopedCache, Scope } from "effect" import * as EffectLogger from "@opencode-ai/core/effect/logger" -import { Instance } from "@/project/instance" import type { InstanceContext } from "@/project/instance-context" -import { LocalContext } from "@/util/local-context" import { InstanceRef, WorkspaceRef } from "./instance-ref" import { registerDisposer } from "./instance-registry" import { WorkspaceContext } from "@/control-plane/workspace-context" @@ -14,20 +12,10 @@ export interface InstanceState { readonly cache: ScopedCache.ScopedCache } -export const bind = any>(fn: F): F => { - try { - return Instance.bind(fn) - } catch (err) { - if (!(err instanceof LocalContext.NotFound)) throw err - } - const fiber = Fiber.getCurrent() - const ctx = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined - if (!ctx) return fn - return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F -} - export const context = Effect.gen(function* () { - return (yield* InstanceRef) ?? Instance.current + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die(new Error("InstanceRef not provided")) + return ctx }) export const workspaceID = Effect.gen(function* () { diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index 75cc0d58b7..a9c454f78b 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -1,7 +1,5 @@ import { Effect, Fiber, Layer, ManagedRuntime } from "effect" import * as Context from "effect/Context" -import { Instance } from "@/project/instance" -import { LocalContext } from "@/util/local-context" import { InstanceRef, WorkspaceRef } from "./instance-ref" import * as Observability from "@opencode-ai/core/effect/observability" import { WorkspaceContext } from "@/control-plane/workspace-context" @@ -25,17 +23,9 @@ export function attachWith(effect: Effect.Effect, refs: Refs): export function attach(effect: Effect.Effect): Effect.Effect { const workspace = WorkspaceContext.workspaceID - const instance = (() => { - try { - return Instance.current - } catch (err) { - if (!(err instanceof LocalContext.NotFound)) throw err - } - })() - if (instance && workspace !== undefined) return attachWith(effect, { instance, workspace }) const fiber = Fiber.getCurrent() return attachWith(effect, { - instance: instance ?? (fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined), + instance: fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined, workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined), }) } diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 30577a8f11..6b7f0c0603 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -7,10 +7,13 @@ import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types import * as Log from "@opencode-ai/core/util/log" import { Process } from "@/util/process" import { LANGUAGE_EXTENSIONS } from "./language" -import { Schema } from "effect" +import { Effect, Schema } from "effect" import type * as LSPServer from "./server" import { withTimeout } from "../util/timeout" import { Filesystem } from "@/util/filesystem" +import { InstanceRef } from "@/effect/instance-ref" +import { makeRuntime } from "@/effect/run-service" +import { context, type InstanceContext } from "@/project/instance-context" const DIAGNOSTICS_DEBOUNCE_MS = 150 const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000 @@ -25,6 +28,7 @@ const FILE_CHANGE_CHANGED = 2 const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2 const log = Log.create({ service: "lsp.client" }) +const busRuntime = makeRuntime(Bus.Service, Bus.layer) export type Info = NonNullable>> @@ -134,9 +138,16 @@ function shouldSeedDiagnosticsOnFirstPush(serverID: string) { return serverID === "typescript" } -export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) { +export async function create(input: { + serverID: string + server: LSPServer.Handle + root: string + directory: string + instance?: InstanceContext +}) { const logger = log.clone().tag("serverID", input.serverID) logger.info("starting client") + const instance = input.instance ?? context.use() const connection = createMessageConnection( new StreamMessageReader(input.server.process.stdout as any), @@ -162,7 +173,11 @@ export async function create(input: { serverID: string; server: LSPServer.Handle dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])]) const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => { pushDiagnostics.set(filePath, next) - Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) + void busRuntime.runPromise((svc) => + svc.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }).pipe( + Effect.provideService(InstanceRef, instance), + ), + ) } const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => { pullDiagnostics.set(filePath, next) @@ -510,10 +525,14 @@ export async function create(input: { serverID: string; server: LSPServer.Handle } timeoutTimer = setTimeout(() => finish(false), request.timeout) - unsub = Bus.subscribe(Event.Diagnostics, (event) => { - if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return - schedule() - }) + unsub = busRuntime.runSync((svc) => + svc + .subscribeCallback(Event.Diagnostics, (event) => { + if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return + schedule() + }) + .pipe(Effect.provideService(InstanceRef, instance)), + ) schedule() }) } diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index d74bbae933..307e85ae73 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -237,6 +237,7 @@ export const layer = Layer.effect( server: handle, root, directory: ctx.directory, + instance: ctx, }).catch(async (err) => { s.broken.add(key) await Process.stop(handle.process) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts deleted file mode 100644 index a54291cf0c..0000000000 --- a/packages/opencode/src/project/instance.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { context, type InstanceContext } from "./instance-context" - -export type { InstanceContext } from "./instance-context" - -export const Instance = { - get current() { - return context.use() - }, - get directory() { - return context.use().directory - }, - get worktree() { - return context.use().worktree - }, - get project() { - return context.use().project - }, - - /** - * Captures the current instance ALS context and returns a wrapper that - * restores it when called. Use this for callbacks that fire outside the - * instance async context (native addons, event emitters, timers, etc.). - */ - bind any>(fn: F): F { - const ctx = context.use() - return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F - }, - /** - * Run a synchronous function within the given instance context ALS. - * Use this to bridge from Effect (where InstanceRef carries context) - * back to sync code that reads Instance.directory from ALS. - */ - restore(ctx: InstanceContext, fn: () => R): R { - return context.provide(ctx, fn) - }, -} diff --git a/packages/opencode/src/project/with-instance.ts b/packages/opencode/src/project/with-instance.ts index b7b5360c75..27360736a9 100644 --- a/packages/opencode/src/project/with-instance.ts +++ b/packages/opencode/src/project/with-instance.ts @@ -1,12 +1,12 @@ import { AppRuntime } from "@/effect/app-runtime" -import { context } from "./instance-context" +import type { InstanceContext } from "./instance-context" import { InstanceStore } from "./instance-store" -export async function provide(input: { directory: string; fn: () => R }): Promise { +export async function provide(input: { directory: string; fn: (ctx: InstanceContext) => R }): Promise { const ctx = await AppRuntime.runPromise( InstanceStore.Service.use((store) => store.load({ directory: input.directory })), ) - return context.provide(ctx, () => input.fn()) + return input.fn(ctx) } export * as WithInstance from "./with-instance" diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index d4913696d2..90f3ce4773 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,4 +1,4 @@ -import { WorkspaceRef } from "@/effect/instance-ref" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { InstanceStore } from "@/project/instance-store" import { Effect, Layer } from "effect" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" @@ -26,9 +26,10 @@ function provideInstanceContext( ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext - return yield* store.provide( - { directory: decode(route.directory) }, - effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)), + const ctx = yield* store.load({ directory: decode(route.directory) }) + return yield* effect.pipe( + Effect.provideService(InstanceRef, ctx), + Effect.provideService(WorkspaceRef, route.workspaceID), ) }) } diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 1b3a6152ef..506d98466e 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -609,10 +609,10 @@ export const ShellTool = Tool.define( parameters: prompt.parameters, execute: (params: Parameters, ctx: Tool.Context) => Effect.gen(function* () { - const executeInstance = yield* InstanceState.context + const instanceCtx = yield* InstanceState.context const cwd = params.workdir - ? yield* resolvePath(params.workdir, executeInstance.directory, shell) - : executeInstance.directory + ? yield* resolvePath(params.workdir, instanceCtx.directory, shell) + : instanceCtx.directory if (params.timeout !== undefined && params.timeout < 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } @@ -623,8 +623,8 @@ export const ShellTool = Tool.define( const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) => Effect.sync(() => tree.delete()), ) - const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance) - if (!containsPath(cwd, executeInstance)) scan.dirs.add(cwd) + const scan = yield* collect(tree.rootNode, cwd, ps, shell, instanceCtx) + if (!containsPath(cwd, instanceCtx)) scan.dirs.add(cwd) yield* ask(ctx, scan) }), ) diff --git a/packages/opencode/test/AGENTS.md b/packages/opencode/test/AGENTS.md index bff1ff5dde..464b75cd82 100644 --- a/packages/opencode/test/AGENTS.md +++ b/packages/opencode/test/AGENTS.md @@ -116,7 +116,7 @@ describe("my service", () => { Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a manual runtime in each test. - `tmpdirScoped(options?)` creates a scoped temp directory and cleans it up when the Effect scope closes. -- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it just runs an Effect with `Instance.current` bound to `dir`. +- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it runs an Effect with `InstanceRef` provided for `dir`. - `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup. - `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server. diff --git a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts index 122b87f174..c8ed9722e7 100644 --- a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts +++ b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts @@ -3,7 +3,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect } from "effect" import { fileURLToPath } from "url" import { InstanceRef } from "../../src/effect/instance-ref" -import { Instance } from "../../src/project/instance" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -13,46 +12,28 @@ afterEach(async () => { await disposeAllInstances() }) -// Regression for PR #25522: when an effectCmd handler does -// `yield* Effect.promise(async () => { ... await runPromise(svcMethod) ... })`, -// the inner runPromise creates a fresh fiber after `await` whose Effect context -// has lost the outer InstanceRef. Services that read `InstanceState.context` -// then fall back to `Instance.current` ALS, which must be installed at the JS -// callback boundary (Node ALS persists across awaits, Effect's fiber context -// does not). `it.instance` provides the loaded InstanceRef; the explicit -// Instance.restore mirrors effectCmd's load + ALS-restore wrap. -// Pins effect-cmd.ts directly: the pattern test below exercises the load + -// Instance.restore boundary via the shared `it.instance` fixture, -// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't -// fail it. This grep guards the actual production callsite. -it.live("effect-cmd.ts wraps the handler body in Instance.restore", () => +it.live("effect-cmd.ts does not restore legacy instance ALS", () => Effect.gen(function* () { const fs = yield* AppFileSystem.Service const source = yield* fs.readFileString(fileURLToPath(new URL("../../src/cli/effect-cmd.ts", import.meta.url))) - expect(source).toContain("Instance.restore(ctx") + expect(source).not.toContain("restore(ctx") }), ) it.instance( - "Instance.current reachable after await inside restored Effect.promise(async)", + "InstanceRef remains the handler context across Effect promise awaits", () => Effect.gen(function* () { const test = yield* TestInstance const ctx = yield* InstanceRef if (!ctx) throw new Error("InstanceRef not provided") - const current = yield* Effect.promise(() => - Instance.restore(ctx, async () => { - await Promise.resolve() - try { - return Instance.current - } catch { - return undefined - } - }), - ) + const directory = yield* Effect.promise(async () => { + await Promise.resolve() + return ctx.directory + }) - expect(current?.directory).toBe(test.directory) + expect(directory).toBe(test.directory) }), { git: true }, ) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 90e78efcdb..e270b1e363 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -6,7 +6,8 @@ import { ConfigManaged } from "@/config/managed" import { ConfigParse } from "../../src/config/parse" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" -import { Instance } from "../../src/project/instance" +import { InstanceRef } from "../../src/effect/instance-ref" +import type { InstanceContext } from "../../src/project/instance-context" import { WithInstance } from "../../src/project/with-instance" import { Auth } from "../../src/auth" import { Account } from "../../src/account/account" @@ -61,9 +62,20 @@ const layer = Config.layer.pipe( 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 provideCurrentInstance = (effect: Effect.Effect, ctx: InstanceContext) => + effect.pipe(Effect.provideService(InstanceRef, ctx)) + +const load = (ctx: InstanceContext) => + Effect.runPromise( + Config.Service.use((svc) => provideCurrentInstance(svc.get(), ctx)).pipe(Effect.scoped, Effect.provide(layer)), + ) +const save = (config: Config.Info, ctx: InstanceContext) => + Effect.runPromise( + Config.Service.use((svc) => provideCurrentInstance(svc.update(config), ctx)).pipe( + Effect.scoped, + Effect.provide(layer), + ), + ) const saveGlobal = (config: Config.Info) => Effect.runPromise( Config.Service.use((svc) => svc.updateGlobal(config)).pipe( @@ -76,10 +88,20 @@ 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 = () => - Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer))) +const listDirs = (ctx: InstanceContext) => + Effect.runPromise( + Config.Service.use((svc) => provideCurrentInstance(svc.directories(), ctx)).pipe( + Effect.scoped, + Effect.provide(layer), + ), + ) +const ready = (ctx: InstanceContext) => + Effect.runPromise( + Config.Service.use((svc) => provideCurrentInstance(svc.waitForDependencies(), ctx)).pipe( + Effect.scoped, + Effect.provide(layer), + ), + ) // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! @@ -116,11 +138,11 @@ async function check(map: (dir: string) => string) { }) await WithInstance.provide({ directory: map(tmp.path), - fn: async () => { - const cfg = await load() + fn: async (ctx) => { + const cfg = await load(ctx) expect(cfg.snapshot).toBe(true) - expect(Instance.directory).toBe(Filesystem.resolve(tmp.path)) - expect(Instance.project.id).not.toBe(ProjectID.global) + expect(ctx.directory).toBe(Filesystem.resolve(tmp.path)) + expect(ctx.project.id).not.toBe(ProjectID.global) }, }) } finally { @@ -134,8 +156,8 @@ test("loads config with defaults when no files exist", async () => { await using tmp = await tmpdir() await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.username).toBeDefined() }, }) @@ -150,8 +172,8 @@ test("creates global jsonc config with schema when no global configs exist", asy try { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - await load() + fn: async (ctx) => { + await load(ctx) }, }) @@ -175,8 +197,8 @@ test("does not create global config when OPENCODE_CONFIG_DIR is set", async () = try { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - await load() + fn: async (ctx) => { + await load(ctx) }, }) @@ -201,8 +223,8 @@ test("loads JSON config file", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.model).toBe("test/model") expect(config.username).toBe("testuser") }, @@ -220,8 +242,8 @@ test("loads shell config field", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.shell).toBe("bash") }, }) @@ -242,8 +264,8 @@ test("updates config and preserves empty shell sentinel", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - await save({ shell: "" }) + fn: async (ctx) => { + await save({ shell: "" }, ctx) const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "config.json")) expect(writtenConfig.shell).toBe("") @@ -320,8 +342,8 @@ test("loads formatter boolean config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.formatter).toBe(true) }, }) @@ -338,8 +360,8 @@ test("loads lsp boolean config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.lsp).toBe(true) }, }) @@ -375,8 +397,8 @@ test("ignores legacy tui keys in opencode config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.model).toBe("test/model") expect((config as Record).theme).toBeUndefined() expect((config as Record).tui).toBeUndefined() @@ -400,8 +422,8 @@ test("loads JSONC config file", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.model).toBe("test/model") expect(config.username).toBe("testuser") }, @@ -428,8 +450,8 @@ test("jsonc overrides json in the same directory", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.model).toBe("base") expect(config.username).toBe("base") }, @@ -451,8 +473,8 @@ test("handles environment variable substitution", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.username).toBe("test-user") }, }) @@ -483,8 +505,8 @@ test("preserves env variables when adding $schema to config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.username).toBe("secret_value") // Read the file to verify the env variable was preserved @@ -580,8 +602,8 @@ test("handles file inclusion substitution", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.username).toBe("test-user") }, }) @@ -599,8 +621,8 @@ test("handles file inclusion with replacement tokens", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.username).toBe("const out = await Bun.$`echo hi`") }, }) @@ -617,9 +639,9 @@ test("validates config schema and throws on invalid fields", async () => { }) await provideTestInstance({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { // Strict schema should throw an error for invalid fields - await expect(load()).rejects.toThrow() + await expect(load(ctx)).rejects.toThrow() }, }) }) @@ -632,8 +654,8 @@ test("throws error for invalid JSON", async () => { }) await provideTestInstance({ directory: tmp.path, - fn: async () => { - await expect(load()).rejects.toThrow() + fn: async (ctx) => { + await expect(load(ctx)).rejects.toThrow() }, }) }) @@ -655,8 +677,8 @@ test("handles agent configuration", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test_agent"]).toEqual( expect.objectContaining({ model: "test/model", @@ -686,8 +708,8 @@ test("treats agent variant as model-scoped setting (not provider option)", async await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) const agent = config.agent?.["test_agent"] expect(agent?.variant).toBe("xhigh") @@ -716,8 +738,8 @@ test("handles command configuration", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.command?.["test_command"]).toEqual({ template: "test template", description: "test command", @@ -741,8 +763,8 @@ test("migrates autoshare to share field", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.share).toBe("auto") expect(config.autoshare).toBe(true) }, @@ -768,8 +790,8 @@ test("migrates mode field to agent field", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test_mode"]).toEqual({ model: "test/model", temperature: 0.5, @@ -800,8 +822,8 @@ Test agent prompt`, }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test"]).toEqual( expect.objectContaining({ name: "test", @@ -833,8 +855,8 @@ Ordered permissions`, }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(Object.keys(config.agent?.ordered?.permission ?? {})).toEqual(["bash", "*", "edit"]) }, }) @@ -871,8 +893,8 @@ Nested agent prompt`, await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["helper"]).toMatchObject({ name: "helper", @@ -920,8 +942,8 @@ Nested command template`, await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.command?.["hello"]).toEqual({ description: "Test command", @@ -965,8 +987,8 @@ Nested command template`, await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.command?.["hello"]).toEqual({ description: "Test command", @@ -985,9 +1007,9 @@ test("updates config and writes to file", async () => { await using tmp = await tmpdir() await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { const newConfig = { model: "updated/model" } - await save(newConfig as any) + await save(newConfig as any, ctx) const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json")) expect(writtenConfig.model).toBe("updated/model") @@ -999,8 +1021,8 @@ test("gets config directories", async () => { await using tmp = await tmpdir() await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const dirs = await listDirs() + fn: async (ctx) => { + const dirs = await listDirs(ctx) expect(dirs.length).toBeGreaterThanOrEqual(1) }, }) @@ -1029,8 +1051,8 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as try { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - await load() + fn: async (ctx) => { + await load(ctx) }, }) } finally { @@ -1064,10 +1086,18 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { try { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - await Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer))) + fn: async (ctx) => { await Effect.runPromise( - Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(testLayer)), + Config.Service.use((svc) => svc.get().pipe(Effect.provideService(InstanceRef, ctx))).pipe( + Effect.scoped, + Effect.provide(testLayer), + ), + ) + await Effect.runPromise( + Config.Service.use((svc) => svc.waitForDependencies().pipe(Effect.provideService(InstanceRef, ctx))).pipe( + Effect.scoped, + Effect.provide(testLayer), + ), ) }, }) @@ -1123,8 +1153,8 @@ test("resolves scoped npm plugins in config", async () => { await provideTestInstance({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) const pluginEntries = config.plugin ?? [] expect(pluginEntries).toContain("@scope/plugin") }, @@ -1161,8 +1191,8 @@ test("merges plugin arrays from global and local configs", async () => { await provideTestInstance({ directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) const plugins = config.plugin ?? [] // Should contain both global and local plugins @@ -1197,8 +1227,8 @@ Helper subagent prompt`, }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["helper"]).toMatchObject({ name: "helper", model: "test/model", @@ -1236,8 +1266,8 @@ test("merges instructions arrays from global and local configs", async () => { await WithInstance.provide({ directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) const instructions = config.instructions ?? [] expect(instructions).toContain("global-instructions.md") @@ -1275,8 +1305,8 @@ test("deduplicates duplicate instructions from global and local configs", async await WithInstance.provide({ directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) const instructions = config.instructions ?? [] expect(instructions).toContain("global-only.md") @@ -1320,8 +1350,8 @@ test("deduplicates duplicate plugins from global and local configs", async () => await provideTestInstance({ directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) const plugins = config.plugin ?? [] // Should contain all unique plugins @@ -1369,8 +1399,8 @@ test("keeps plugin origins aligned with merged plugin list", async () => { await provideTestInstance({ directory: path.join(tmp.path, "project"), - fn: async () => { - const cfg = await load() + fn: async (ctx) => { + const cfg = await load(ctx) const plugins = cfg.plugin ?? [] const origins = cfg.plugin_origins ?? [] const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item)) @@ -1410,8 +1440,8 @@ test("migrates legacy tools config to permissions - allow", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test"]?.permission).toEqual({ bash: "allow", read: "allow", @@ -1441,8 +1471,8 @@ test("migrates legacy tools config to permissions - deny", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test"]?.permission).toEqual({ bash: "deny", webfetch: "deny", @@ -1471,8 +1501,8 @@ test("migrates legacy write tool to edit permission", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow", }) @@ -1503,8 +1533,8 @@ test("managed settings override user settings", async () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.model).toBe("managed/model") expect(config.share).toBe("disabled") expect(config.username).toBe("testuser") @@ -1531,8 +1561,8 @@ test("managed settings override project settings", async () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.autoupdate).toBe(false) expect(config.disabled_providers).toEqual(["openai"]) }, @@ -1551,8 +1581,8 @@ test("missing managed settings file is not an error", async () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.model).toBe("user/model") }, }) @@ -1578,8 +1608,8 @@ test("migrates legacy edit tool to edit permission", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test"]?.permission).toEqual({ edit: "deny", }) @@ -1607,8 +1637,8 @@ test("migrates legacy patch tool to edit permission", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow", }) @@ -1639,8 +1669,8 @@ test("migrates mixed legacy tools config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test"]?.permission).toEqual({ bash: "allow", edit: "allow", @@ -1674,8 +1704,8 @@ test("merges legacy tools with existing permission config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.agent?.["test"]?.permission).toEqual({ glob: "allow", bash: "allow", @@ -1711,8 +1741,8 @@ test("permission config preserves user key order", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(Object.keys(config.permission!)).toEqual([ "*", "edit", @@ -1794,8 +1824,8 @@ test("project config can override MCP server enabled status", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) // jira should be enabled (overridden by project config) expect(config.mcp?.jira).toEqual({ type: "remote", @@ -1850,8 +1880,8 @@ test("MCP config deep merges preserving base config properties", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.mcp?.myserver).toEqual({ type: "remote", url: "https://myserver.example.com/mcp", @@ -1901,8 +1931,8 @@ test("local .opencode config can override MCP from project config", async () => }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.mcp?.docs?.enabled).toBe(true) }, }) @@ -2235,8 +2265,8 @@ describe("deduplicatePluginOrigins", () => { await provideTestInstance({ directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) const plugins = config.plugin ?? [] expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true) @@ -2267,8 +2297,8 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) // 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") @@ -2298,8 +2328,8 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const directories = await listDirs() + fn: async (ctx) => { + const directories = await listDirs(ctx) // Project .opencode should NOT be in directories list const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path)) expect(hasProjectOpencode).toBe(false) @@ -2322,9 +2352,9 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await using tmp = await tmpdir() await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { // Should still get default config (from global or defaults) - const config = await load() + const config = await load(ctx) expect(config).toBeDefined() expect(config.username).toBeDefined() }, @@ -2364,10 +2394,10 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { // The relative instruction should be skipped without error // We're mainly verifying this doesn't throw and the config loads - const config = await load() + const config = await load(ctx) expect(config).toBeDefined() // The instruction should have been skipped (warning logged) // We can't easily test the warning was logged, but we verify @@ -2424,8 +2454,8 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await WithInstance.provide({ directory: projectTmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) // Should load from OPENCODE_CONFIG_DIR, not project expect(config.model).toBe("configdir/model") }, @@ -2459,8 +2489,8 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { await using tmp = await tmpdir() await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.username).toBe("test_api_key_12345") }, }) @@ -2493,8 +2523,8 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const config = await load() + fn: async (ctx) => { + const config = await load(ctx) expect(config.username).toBe("secret_key_from_file") }, }) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 00cd32a3f7..26784592fb 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -139,7 +139,18 @@ async function initGitRepo(dir: string) { await $`git commit -m "base"`.cwd(dir).quiet() } -const runWorkspace = (effect: Effect.Effect) => AppRuntime.runPromise(effect) +function currentInstance() { + try { + return context.use() + } catch { + return undefined + } +} + +const runWorkspace = (effect: Effect.Effect) => { + const ctx = currentInstance() + return AppRuntime.runPromise(ctx ? effect.pipe(Effect.provideService(InstanceRef, ctx)) : effect) +} const createWorkspace = (input: Workspace.CreateInput) => runWorkspace(Workspace.Service.use((workspace) => workspace.create(input))) const warpWorkspaceSession = (input: Workspace.SessionWarpInput) => @@ -917,7 +928,9 @@ describe("workspace CRUD", () => { const previous = workspaceInfo(projectID, previousType) insertWorkspace(previous) registerAdapter(projectID, previousType, localAdapter(workspaceTmp.path, { createDir: false }).adapter) - const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + const session = await AppRuntime.runPromise( + SessionNs.Service.use((svc) => svc.create({})).pipe(Effect.provideService(InstanceRef, instance)), + ) attachSessionToWorkspace(session.id, previous.id) const workspaceCtx = await AppRuntime.runPromise( diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index b928f16bc6..23cd51d7f3 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -3,7 +3,6 @@ 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 { Instance } from "../../src/project/instance" import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index fedbc246bc..51b6294975 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -11,9 +11,9 @@ 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 type { InstanceContext } from "../../src/project/instance-context" 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" const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) @@ -24,11 +24,15 @@ const testInstanceRuntime = ManagedRuntime.make( const runTestInstanceStore = (fn: (store: InstanceStore.Interface) => Effect.Effect) => testInstanceRuntime.runPromise(InstanceStore.Service.use(fn)) -export async function provideTestInstance(input: { directory: string; init?: Effect.Effect; fn: () => R }) { +export async function provideTestInstance(input: { + directory: string + init?: Effect.Effect + fn: (ctx: InstanceContext) => R +}) { const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory })) try { if (input.init) await testInstanceRuntime.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx))) - return await Instance.restore(ctx, () => input.fn()) + return await input.fn(ctx) } finally { await runTestInstanceStore((store) => store.dispose(ctx)) } @@ -157,9 +161,7 @@ export const provideInstance = Effect.contextWith((services: Context.Context) => Effect.promise(async () => { const ctx = await runTestInstanceStore((store) => store.load({ directory })) - return Instance.restore(ctx, () => - Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))), - ) + return Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))) }), ) diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index 7d9f5a7155..1897e6537d 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -4,7 +4,6 @@ import { pathToFileURL } from "url" import { tmpdir } from "../fixture/fixture" import { LSPClient } from "@/lsp/client" import * as LSPServer from "@/lsp/server" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" @@ -28,12 +27,13 @@ describe("LSPClient interop", () => { const client = await WithInstance.provide({ directory: process.cwd(), - fn: () => + fn: (ctx) => LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), directory: process.cwd(), + instance: ctx, }), }) @@ -51,12 +51,13 @@ describe("LSPClient interop", () => { const client = await WithInstance.provide({ directory: process.cwd(), - fn: () => + fn: (ctx) => LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), directory: process.cwd(), + instance: ctx, }), }) @@ -74,12 +75,13 @@ describe("LSPClient interop", () => { const client = await WithInstance.provide({ directory: process.cwd(), - fn: () => + fn: (ctx) => LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), directory: process.cwd(), + instance: ctx, }), }) @@ -97,12 +99,13 @@ describe("LSPClient interop", () => { const client = await WithInstance.provide({ directory: process.cwd(), - fn: () => + fn: (ctx) => LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), directory: process.cwd(), + instance: ctx, }), }) @@ -124,7 +127,7 @@ describe("LSPClient interop", () => { const client = await WithInstance.provide({ directory: process.cwd(), - fn: () => + fn: (ctx) => LSPClient.create({ serverID: "fake", server: { @@ -133,6 +136,7 @@ describe("LSPClient interop", () => { }, root: process.cwd(), directory: process.cwd(), + instance: ctx, }), }) @@ -153,12 +157,13 @@ describe("LSPClient interop", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { const client = await LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, directory: tmp.path, + instance: ctx, }) await client.notify.open({ path: file }) @@ -196,12 +201,13 @@ describe("LSPClient interop", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { const client = await LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, directory: tmp.path, + instance: ctx, }) const version = await client.notify.open({ path: file }) @@ -242,12 +248,13 @@ describe("LSPClient interop", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { const client = await LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, directory: tmp.path, + instance: ctx, }) const version = await client.notify.open({ path: file }) @@ -289,12 +296,13 @@ describe("LSPClient interop", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { const client = await LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, directory: tmp.path, + instance: ctx, }) await client.connection.sendRequest("test/configure-pull-diagnostics", { @@ -337,12 +345,13 @@ describe("LSPClient interop", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { const client = await LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, directory: tmp.path, + instance: ctx, }) await client.connection.sendRequest("test/configure-pull-diagnostics", { @@ -390,12 +399,13 @@ describe("LSPClient interop", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { const client = await LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, directory: tmp.path, + instance: ctx, }) await client.connection.sendRequest("test/configure-pull-diagnostics", { @@ -454,12 +464,13 @@ describe("LSPClient interop", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { const client = await LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, directory: tmp.path, + instance: ctx, }) await client.connection.sendRequest("test/configure-pull-diagnostics", { diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 41dbf53445..b4b40fe767 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -15,10 +15,10 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Workspace } from "../../src/control-plane/workspace" import { Plugin } from "../../src/plugin/index" import { InstanceBootstrap } from "../../src/project/bootstrap-service" -import { Instance } from "../../src/project/instance" import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { Vcs } from "../../src/project/vcs" +import { InstanceState } from "../../src/effect/instance-state" import { Session } from "../../src/session/session" import { SessionPrompt } from "../../src/session/prompt" import { SyncEvent } from "../../src/sync" @@ -116,11 +116,12 @@ describe("plugin.workspace", () => { const plugin = yield* Plugin.Service yield* plugin.init() const workspace = yield* Workspace.Service + const ctx = yield* InstanceState.context const info = yield* workspace.create({ type, branch: null, extra: { key: "value" }, - projectID: Instance.project.id, + projectID: ctx.project.id, }) expect(info.type).toBe(type) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 9c0f9150e1..491cfe93d7 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -4,9 +4,8 @@ import { Deferred, 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 { TestInstance, tmpdirScoped } from "../fixture/fixture" +import { tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" let bootstrapRun: Effect.Effect = Effect.void @@ -37,7 +36,7 @@ const registerDisposerScoped = (disposer: (directory: string) => Promise) ) describe("InstanceStore", () => { - it.live("loads instance context without installing ALS for the caller", () => + it.live("loads instance context", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service @@ -45,7 +44,6 @@ describe("InstanceStore", () => { expect(ctx.directory).toBe(dir) expect(ctx.worktree).toBe(dir) - expect(() => Instance.current).toThrow() }), ) @@ -63,7 +61,6 @@ describe("InstanceStore", () => { yield* store.load({ directory: dir }) expect(initializedDirectory).toBe(dir) - expect(() => Instance.current).toThrow() }), ) @@ -245,20 +242,4 @@ describe("InstanceStore", () => { expect(disposed).toEqual([dir1, dir2]) }), ) - - it.instance( - "provides legacy Promise callers with instance ALS", - () => - Effect.gen(function* () { - const test = yield* TestInstance - const ctx = yield* InstanceRef - if (!ctx) throw new Error("InstanceRef not provided") - - const directory = yield* Effect.promise(() => Promise.resolve(Instance.restore(ctx, () => Instance.directory))) - - expect(directory).toBe(test.directory) - expect(() => Instance.current).toThrow() - }), - { git: true }, - ) }) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 308e2f957b..688b818bee 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Git } from "../../src/git" -import { Instance } from "../../src/project/instance" +import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceRuntime } from "../../src/project/instance-runtime" import { Worktree } from "../../src/worktree" import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" @@ -41,7 +41,10 @@ const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () { const removeCreatedWorktree = (directory: string) => Effect.gen(function* () { const svc = yield* Worktree.Service - const ctx = yield* Effect.sync(() => Instance.current).pipe(provideInstance(directory)) + const ctx = yield* Effect.gen(function* () { + return yield* InstanceRef + }).pipe(provideInstance(directory)) + if (!ctx) return yield* Effect.die(new Error("missing test instance")) yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx)) const ok = yield* svc.remove({ directory }) if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${directory}`)) diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index c35a03d78b..35824fb2f7 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -1,10 +1,10 @@ -import { test, expect, describe } from "bun:test" +import { afterEach, test, expect, describe } from "bun:test" import path from "path" import { unlink } from "fs/promises" import { ProviderID } from "../../src/provider/schema" -import { tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import type { InstanceContext } from "../../src/project/instance-context" import { WithInstance } from "../../src/project/with-instance" import { Provider } from "@/provider/provider" import { Env } from "../../src/env" @@ -12,17 +12,37 @@ import { Global } from "@opencode-ai/core/global" import { Filesystem } from "@/util/filesystem" import { Effect } from "effect" import { AppRuntime } from "../../src/effect/app-runtime" +import { InstanceRef } from "../../src/effect/instance-ref" import { makeRuntime } from "../../src/effect/run-service" const env = makeRuntime(Env.Service, Env.defaultLayer) -const set = (k: string, v: string) => env.runSync((svc) => svc.set(k, v)) +const originalEnv = new Map() -async function list() { +function rememberEnv(k: string) { + if (!originalEnv.has(k)) originalEnv.set(k, process.env[k]) +} + +const set = (ctx: InstanceContext, k: string, v: string) => { + rememberEnv(k) + process.env[k] = v + return env.runSync((svc) => svc.set(k, v).pipe(Effect.provideService(InstanceRef, ctx))) +} + +afterEach(async () => { + for (const [key, value] of originalEnv) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + originalEnv.clear() + await disposeAllInstances() +}) + +async function list(ctx: InstanceContext) { return AppRuntime.runPromise( Effect.gen(function* () { const provider = yield* Provider.Service return yield* provider.list() - }), + }).pipe(Effect.provideService(InstanceRef, ctx)), ) } @@ -46,10 +66,10 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async () }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_REGION", "us-east-1") - set("AWS_PROFILE", "default") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_REGION", "us-east-1") + set(ctx, "AWS_PROFILE", "default") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") }, @@ -69,10 +89,10 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async () }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_REGION", "eu-west-1") - set("AWS_PROFILE", "default") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_REGION", "eu-west-1") + set(ctx, "AWS_PROFILE", "default") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") }, @@ -122,11 +142,11 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_PROFILE", "") - set("AWS_ACCESS_KEY_ID", "") - set("AWS_BEARER_TOKEN_BEDROCK", "") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_PROFILE", "") + set(ctx, "AWS_ACCESS_KEY_ID", "") + set(ctx, "AWS_BEARER_TOKEN_BEDROCK", "") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") }, @@ -166,10 +186,10 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_PROFILE", "default") - set("AWS_ACCESS_KEY_ID", "test-key-id") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_PROFILE", "default") + set(ctx, "AWS_ACCESS_KEY_ID", "test-key-id") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") }, @@ -196,9 +216,9 @@ test("Bedrock: includes custom endpoint in options when specified", async () => }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_PROFILE", "default") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_PROFILE", "default") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe( "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com", @@ -227,12 +247,12 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") - set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role") - set("AWS_PROFILE", "") - set("AWS_ACCESS_KEY_ID", "") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") + set(ctx, "AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role") + set(ctx, "AWS_PROFILE", "") + set(ctx, "AWS_ACCESS_KEY_ID", "") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") }, @@ -268,9 +288,9 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () => }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_PROFILE", "default") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_PROFILE", "default") + const providers = await list(ctx) 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() @@ -303,9 +323,9 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_PROFILE", "default") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_PROFILE", "default") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() }, @@ -337,9 +357,9 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () => }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_PROFILE", "default") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_PROFILE", "default") + const providers = await list(ctx) expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() }, @@ -371,9 +391,9 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("AWS_PROFILE", "default") - const providers = await list() + fn: async (ctx) => { + set(ctx, "AWS_PROFILE", "default") + const providers = await list(ctx) 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 8bb3b96347..4dd762f67c 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -8,7 +8,6 @@ export {} // import { ProviderID, ModelID } from "../../src/provider/schema" // import { tmpdir } from "../fixture/fixture" -// import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" // import { Provider } from "@/provider/provider" // import { Env } from "../../src/env" diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 1c6a8b3377..579867b2a2 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1,10 +1,10 @@ -import { test, expect } from "bun:test" +import { afterEach, test, expect } from "bun:test" import { mkdir, unlink } from "fs/promises" import path from "path" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Global } from "@opencode-ai/core/global" -import { Instance } from "../../src/project/instance" +import type { InstanceContext } from "../../src/project/instance-context" import { WithInstance } from "../../src/project/with-instance" import { Plugin } from "../../src/plugin/index" import { ModelsDev } from "@opencode-ai/core/models" @@ -14,6 +14,7 @@ import { Filesystem } from "@/util/filesystem" import { Env } from "../../src/env" import { Effect, Layer } from "effect" import { AppRuntime } from "../../src/effect/app-runtime" +import { InstanceRef } from "../../src/effect/instance-ref" import { makeRuntime } from "../../src/effect/run-service" import { testEffect } from "../lib/effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -22,8 +23,31 @@ import { Auth } from "@/auth" import { RuntimeFlags } from "@/effect/runtime-flags" const env = makeRuntime(Env.Service, Env.defaultLayer) -const set = (k: string, v: string) => env.runSync((svc) => svc.set(k, v)) -const remove = (k: string) => env.runSync((svc) => svc.remove(k)) +const originalEnv = new Map() + +function rememberEnv(k: string) { + if (!originalEnv.has(k)) originalEnv.set(k, process.env[k]) +} + +const set = (ctx: InstanceContext, k: string, v: string) => { + rememberEnv(k) + process.env[k] = v + return env.runSync((svc) => svc.set(k, v).pipe(Effect.provideService(InstanceRef, ctx))) +} +const remove = (ctx: InstanceContext, k: string) => { + rememberEnv(k) + delete process.env[k] + return env.runSync((svc) => svc.remove(k).pipe(Effect.provideService(InstanceRef, ctx))) +} + +afterEach(async () => { + for (const [key, value] of originalEnv) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + originalEnv.clear() + await disposeAllInstances() +}) const providerLayer = (flags: Partial = {}) => Provider.layer.pipe( @@ -36,41 +60,41 @@ const providerLayer = (flags: Partial = {}) => Layer.provide(RuntimeFlags.layer(flags)), ) -async function run(fn: (provider: Provider.Interface) => Effect.Effect) { +async function run(ctx: InstanceContext, fn: (provider: Provider.Interface) => Effect.Effect) { return AppRuntime.runPromise( Effect.gen(function* () { const provider = yield* Provider.Service return yield* fn(provider) - }), + }).pipe(Effect.provideService(InstanceRef, ctx)), ) } -async function list() { - return run((provider) => provider.list()) +async function list(ctx: InstanceContext) { + return run(ctx, (provider) => provider.list()) } -async function getProvider(providerID: ProviderID) { - return run((provider) => provider.getProvider(providerID)) +async function getProvider(providerID: ProviderID, ctx: InstanceContext) { + return run(ctx, (provider) => provider.getProvider(providerID)) } -async function getModel(providerID: ProviderID, modelID: ModelID) { - return run((provider) => provider.getModel(providerID, modelID)) +async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) { + return run(ctx, (provider) => provider.getModel(providerID, modelID)) } -async function getLanguage(model: Provider.Model) { - return run((provider) => provider.getLanguage(model)) +async function getLanguage(model: Provider.Model, ctx: InstanceContext) { + return run(ctx, (provider) => provider.getLanguage(model)) } -async function closest(providerID: ProviderID, query: string[]) { - return run((provider) => provider.closest(providerID, query)) +async function closest(providerID: ProviderID, query: string[], ctx: InstanceContext) { + return run(ctx, (provider) => provider.closest(providerID, query)) } -async function getSmallModel(providerID: ProviderID) { - return run((provider) => provider.getSmallModel(providerID)) +async function getSmallModel(providerID: ProviderID, ctx: InstanceContext) { + return run(ctx, (provider) => provider.getSmallModel(providerID)) } -async function defaultModel() { - return run((provider) => provider.defaultModel()) +async function defaultModel(ctx: InstanceContext) { + return run(ctx, (provider) => provider.defaultModel()) } async function markPluginDependenciesReady(dir: string) { @@ -125,9 +149,9 @@ test("provider loaded from env variable", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() // Provider should retain its connection source even if custom loaders // merge additional options. @@ -157,8 +181,8 @@ test("provider loaded from config with apiKey option", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() }, }) @@ -178,9 +202,9 @@ test("disabled_providers excludes provider", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeUndefined() }, }) @@ -200,10 +224,10 @@ test("enabled_providers restricts to only listed providers", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - set("OPENAI_API_KEY", "test-openai-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + set(ctx, "OPENAI_API_KEY", "test-openai-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() }, @@ -228,9 +252,9 @@ test("model whitelist filters models for provider", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) expect(models).toContain("claude-sonnet-4-20250514") @@ -257,9 +281,9 @@ test("model blacklist excludes specific models", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) expect(models).not.toContain("claude-sonnet-4-20250514") @@ -290,9 +314,9 @@ test("custom model alias via config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) 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") @@ -334,8 +358,8 @@ test("custom provider with npm package", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) 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() @@ -411,8 +435,8 @@ test("custom DeepSeek openai-compatible model defaults interleaved reasoning fie }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) const provider = providers[ProviderID.make("custom-provider")] expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" }) expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" }) @@ -445,9 +469,9 @@ test("env variable takes precedence, config merges options", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "env-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "env-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() // Config options should be merged expect(providers[ProviderID.anthropic].options.timeout).toBe(60000) @@ -469,13 +493,13 @@ test("getModel returns model for valid provider/model", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx) expect(model).toBeDefined() expect(String(model.providerID)).toBe("anthropic") expect(String(model.id)).toBe("claude-sonnet-4-20250514") - const language = await getLanguage(model) + const language = await getLanguage(model, ctx) expect(language).toBeDefined() }, }) @@ -494,9 +518,9 @@ test("getModel throws ModelNotFoundError for invalid model", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"), ctx)).rejects.toThrow() }, }) }) @@ -514,8 +538,8 @@ test("getModel throws ModelNotFoundError for invalid provider", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow() + fn: async (ctx) => { + expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"), ctx)).rejects.toThrow() }, }) }) @@ -545,9 +569,9 @@ test("defaultModel returns first available model when no config set", async () = }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const model = await defaultModel() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const model = await defaultModel(ctx) expect(model.providerID).toBeDefined() expect(model.modelID).toBeDefined() }, @@ -568,9 +592,9 @@ test("defaultModel respects config model setting", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const model = await defaultModel() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const model = await defaultModel(ctx) expect(String(model.providerID)).toBe("anthropic") expect(String(model.modelID)).toBe("claude-sonnet-4-20250514") }, @@ -701,9 +725,9 @@ test("closest finds model by partial match", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const result = await closest(ProviderID.anthropic, ["sonnet-4"]) + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const result = await closest(ProviderID.anthropic, ["sonnet-4"], ctx) expect(result).toBeDefined() expect(String(result?.providerID)).toBe("anthropic") expect(String(result?.modelID)).toContain("sonnet-4") @@ -724,8 +748,8 @@ test("closest returns undefined for nonexistent provider", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const result = await closest(ProviderID.make("nonexistent"), ["model"]) + fn: async (ctx) => { + const result = await closest(ProviderID.make("nonexistent"), ["model"], ctx) expect(result).toBeUndefined() }, }) @@ -754,12 +778,12 @@ test("getModel uses realIdByKey for aliased models", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined() - const model = await getModel(ProviderID.anthropic, ModelID.make("my-sonnet")) + const model = await getModel(ProviderID.anthropic, ModelID.make("my-sonnet"), ctx) expect(model).toBeDefined() expect(String(model.id)).toBe("my-sonnet") expect(model.name).toBe("My Sonnet Alias") @@ -798,8 +822,8 @@ test("provider api field sets model api.url", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) // 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") }, @@ -838,8 +862,8 @@ test("explicit baseURL overrides api field", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1") }, }) @@ -867,9 +891,9 @@ test("model inherits properties from existing database model", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.name).toBe("Custom Name for Sonnet") expect(model.capabilities.toolcall).toBe(true) @@ -893,9 +917,9 @@ test("disabled_providers prevents loading even with env var", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("OPENAI_API_KEY", "test-openai-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "OPENAI_API_KEY", "test-openai-key") + const providers = await list(ctx) expect(providers[ProviderID.openai]).toBeUndefined() }, }) @@ -915,10 +939,10 @@ test("enabled_providers with empty array allows no providers", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - set("OPENAI_API_KEY", "test-openai-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + set(ctx, "OPENAI_API_KEY", "test-openai-key") + const providers = await list(ctx) expect(Object.keys(providers).length).toBe(0) }, }) @@ -943,9 +967,9 @@ test("whitelist and blacklist can be combined", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) expect(models).toContain("claude-sonnet-4-20250514") @@ -983,8 +1007,8 @@ test("model modalities default correctly", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) const model = providers[ProviderID.make("test-provider")].models["test-model"] expect(model.capabilities.input.text).toBe(true) expect(model.capabilities.output.text).toBe(true) @@ -1026,8 +1050,8 @@ test("model with custom cost values", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) const model = providers[ProviderID.make("test-provider")].models["test-model"] expect(model.cost.input).toBe(5) expect(model.cost.output).toBe(15) @@ -1050,9 +1074,9 @@ test("getSmallModel returns appropriate small model", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const model = await getSmallModel(ProviderID.anthropic) + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const model = await getSmallModel(ProviderID.anthropic, ctx) expect(model).toBeDefined() expect(model?.id).toContain("haiku") }, @@ -1073,9 +1097,9 @@ test("getSmallModel respects config small_model override", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const model = await getSmallModel(ProviderID.anthropic) + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const model = await getSmallModel(ProviderID.anthropic, ctx) expect(model).toBeDefined() expect(String(model?.providerID)).toBe("anthropic") expect(String(model?.id)).toBe("claude-sonnet-4-20250514") @@ -1097,9 +1121,9 @@ test("getSmallModel ignores invalid config small_model", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - expect(await getSmallModel(ProviderID.anthropic)).toBeUndefined() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + expect(await getSmallModel(ProviderID.anthropic, ctx)).toBeUndefined() }, }) }) @@ -1140,10 +1164,10 @@ test("multiple providers can be configured simultaneously", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-anthropic-key") - set("OPENAI_API_KEY", "test-openai-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key") + set(ctx, "OPENAI_API_KEY", "test-openai-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeDefined() expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) @@ -1183,8 +1207,8 @@ test("provider with custom npm package", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) 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") @@ -1217,9 +1241,9 @@ test("model alias name defaults to alias key when id differs", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet") }, }) @@ -1255,9 +1279,9 @@ test("provider with multiple env var options only includes apiKey when single en }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("MULTI_ENV_KEY_1", "test-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "MULTI_ENV_KEY_1", "test-key") + const providers = await list(ctx) 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() @@ -1295,9 +1319,9 @@ test("provider with single env var includes apiKey automatically", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("SINGLE_ENV_KEY", "my-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "SINGLE_ENV_KEY", "my-api-key") + const providers = await list(ctx) 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") @@ -1330,9 +1354,9 @@ test("model cost overrides existing cost values", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.cost.input).toBe(999) expect(model.cost.output).toBe(888) @@ -1378,8 +1402,8 @@ test("completely new provider not in database can be configured", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) 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"] @@ -1407,11 +1431,11 @@ test("disabled_providers and enabled_providers interaction", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-anthropic") - set("OPENAI_API_KEY", "test-openai") - set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-anthropic") + set(ctx, "OPENAI_API_KEY", "test-openai") + set(ctx, "GOOGLE_GENERATIVE_AI_API_KEY", "test-google") + const providers = await list(ctx) // anthropic: in enabled, not in disabled = allowed expect(providers[ProviderID.anthropic]).toBeDefined() // openai: in enabled, but also in disabled = NOT allowed @@ -1450,8 +1474,8 @@ test("model with tool_call false", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false) }, }) @@ -1485,8 +1509,8 @@ test("model defaults tool_call to true when not specified", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true) }, }) @@ -1524,8 +1548,8 @@ test("model headers are preserved", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) const model = providers[ProviderID.make("headers-provider")].models["model"] expect(model.headers).toEqual({ "X-Custom-Header": "custom-value", @@ -1563,10 +1587,10 @@ test("provider env fallback - second env var used if first missing", async () => }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { + fn: async (ctx) => { // Only set fallback, not primary - set("FALLBACK_KEY", "fallback-api-key") - const providers = await list() + set(ctx, "FALLBACK_KEY", "fallback-api-key") + const providers = await list(ctx) // Provider should load because fallback env var is set expect(providers[ProviderID.make("fallback-env")]).toBeDefined() }, @@ -1586,10 +1610,10 @@ test("getModel returns consistent results", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) - const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx) + const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx) expect(model1.providerID).toEqual(model2.providerID) expect(model1.id).toEqual(model2.id) expect(model1).toEqual(model2) @@ -1625,8 +1649,8 @@ test("provider name defaults to id when not in database", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("my-custom-id")].name).toBe("my-custom-id") }, }) @@ -1645,10 +1669,10 @@ test("ModelNotFoundError includes suggestions for typos", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") try { - await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet + await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4"), ctx) // typo: sonet instead of sonnet expect(true).toBe(false) // Should not reach here } catch (e: any) { expect(e.suggestions).toBeDefined() @@ -1671,10 +1695,10 @@ test("ModelNotFoundError for provider includes suggestions", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") try { - await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic + await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4"), ctx) // typo: antropic expect(true).toBe(false) // Should not reach here } catch (e: any) { expect(e.suggestions).toBeDefined() @@ -1697,10 +1721,10 @@ test("ModelNotFoundError suggests catalog models for unloaded providers", async }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - remove("OPENCODE_API_KEY") + fn: async (ctx) => { + remove(ctx, "OPENCODE_API_KEY") try { - await getModel(ProviderID.opencode, ModelID.make("claude-haiku-fake-model")) + await getModel(ProviderID.opencode, ModelID.make("claude-haiku-fake-model"), ctx) throw new Error("expected model lookup to fail") } catch (e) { if (!Provider.ModelNotFoundError.isInstance(e)) throw e @@ -1723,8 +1747,8 @@ test("getProvider returns undefined for nonexistent provider", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const provider = await getProvider(ProviderID.make("nonexistent")) + fn: async (ctx) => { + const provider = await getProvider(ProviderID.make("nonexistent"), ctx) expect(provider).toBeUndefined() }, }) @@ -1743,9 +1767,9 @@ test("getProvider returns provider info", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const provider = await getProvider(ProviderID.anthropic) + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const provider = await getProvider(ProviderID.anthropic, ctx) expect(provider).toBeDefined() expect(String(provider?.id)).toBe("anthropic") }, @@ -1765,9 +1789,9 @@ test("closest returns undefined when no partial match found", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"], ctx) expect(result).toBeUndefined() }, }) @@ -1786,10 +1810,10 @@ test("closest checks multiple query terms in order", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") // First term won't match, second will - const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"]) + const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"], ctx) expect(result).toBeDefined() expect(result?.modelID).toContain("haiku") }, @@ -1824,8 +1848,8 @@ test("model limit defaults to zero when not specified", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) const model = providers[ProviderID.make("no-limit")].models["model"] expect(model.limit.context).toBe(0) expect(model.limit.output).toBe(0) @@ -1856,9 +1880,9 @@ test("provider options are deeply merged", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) // Custom options should be merged expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) expect(providers[ProviderID.anthropic].options.headers["X-Custom"]).toBe("custom-value") @@ -1888,8 +1912,8 @@ test("hosted nvidia provider adds billing origin header", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("nvidia")].options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", @@ -1920,8 +1944,8 @@ test("custom nvidia baseURL adds billing origin header", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("nvidia")].options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", @@ -1955,8 +1979,8 @@ test("explicit nvidia billing origin header is preserved", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) expect(providers[ProviderID.make("nvidia")].options.headers["X-BILLING-INVOKE-ORIGIN"]).toBe("CustomOrigin") }, }) @@ -1986,9 +2010,9 @@ test("custom model inherits npm package from models.dev provider config", async }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("OPENAI_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "OPENAI_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.openai].models["my-custom-model"] expect(model).toBeDefined() expect(model.api.npm).toBe("@ai-sdk/openai") @@ -2019,9 +2043,9 @@ test("custom model inherits api.url from models.dev provider", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("OPENROUTER_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "OPENROUTER_API_KEY", "test-api-key") + const providers = await list(ctx) expect(providers[ProviderID.openrouter]).toBeDefined() // New model not in database should inherit api.url from provider @@ -2150,9 +2174,9 @@ test("model variants are generated for reasoning models", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) // Claude sonnet 4 has reasoning capability const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.capabilities.reasoning).toBe(true) @@ -2186,9 +2210,9 @@ test("model variants can be disabled via config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() expect(model.variants!["high"]).toBeUndefined() @@ -2227,9 +2251,9 @@ test("model variants can be customized via config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() expect(model.variants!["high"].thinking.budgetTokens).toBe(20000) @@ -2264,9 +2288,9 @@ test("disabled key is stripped from variant config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["max"]).toBeDefined() expect(model.variants!["max"].disabled).toBeUndefined() @@ -2300,9 +2324,9 @@ test("all variants can be disabled via config", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() expect(Object.keys(model.variants!).length).toBe(0) @@ -2336,9 +2360,9 @@ test("variant config merges with generated variants", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-api-key") + const providers = await list(ctx) 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 @@ -2372,9 +2396,9 @@ test("variants filtered in second pass for database models", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("OPENAI_API_KEY", "test-api-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "OPENAI_API_KEY", "test-api-key") + const providers = await list(ctx) const model = providers[ProviderID.openai].models["gpt-5"] expect(model.variants).toBeDefined() expect(model.variants!["high"]).toBeUndefined() @@ -2419,8 +2443,8 @@ test("custom model with variants enabled and disabled", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const providers = await list() + fn: async (ctx) => { + const providers = await list(ctx) const model = providers[ProviderID.make("custom-reasoning")].models["reasoning-model"] expect(model.variants).toBeDefined() // Enabled variants should exist @@ -2474,9 +2498,9 @@ test("Google Vertex: retains baseURL for custom proxy", async () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - const providers = await list() + fn: async (ctx) => { + set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds") + const providers = await list(ctx) expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined() expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1") }, @@ -2517,9 +2541,9 @@ test("Google Vertex: supports OpenAI compatible models", async () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - const providers = await list() + fn: async (ctx) => { + set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds") + const providers = await list(ctx) const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"] expect(model).toBeDefined() @@ -2541,11 +2565,11 @@ test("cloudflare-ai-gateway loads with env variables", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("CLOUDFLARE_ACCOUNT_ID", "test-account") - set("CLOUDFLARE_GATEWAY_ID", "test-gateway") - set("CLOUDFLARE_API_TOKEN", "test-token") - const providers = await list() + fn: async (ctx) => { + set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account") + set(ctx, "CLOUDFLARE_GATEWAY_ID", "test-gateway") + set(ctx, "CLOUDFLARE_API_TOKEN", "test-token") + const providers = await list(ctx) expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() }, }) @@ -2571,11 +2595,11 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { }) await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("CLOUDFLARE_ACCOUNT_ID", "test-account") - set("CLOUDFLARE_GATEWAY_ID", "test-gateway") - set("CLOUDFLARE_API_TOKEN", "test-token") - const providers = await list() + fn: async (ctx) => { + set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account") + set(ctx, "CLOUDFLARE_GATEWAY_ID", "test-gateway") + set(ctx, "CLOUDFLARE_API_TOKEN", "test-token") + const providers = await list(ctx) expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({ invoked_by: "test", @@ -2624,14 +2648,14 @@ test("plugin config providers persist after instance dispose", async () => { const first = await WithInstance.provide({ directory: tmp.path, - fn: async () => + fn: async (ctx) => AppRuntime.runPromise( Effect.gen(function* () { const plugin = yield* Plugin.Service const provider = yield* Provider.Service yield* plugin.init() return yield* provider.list() - }), + }).pipe(Effect.provideService(InstanceRef, ctx)), ), }) expect(first[ProviderID.make("demo")]).toBeDefined() @@ -2641,7 +2665,7 @@ test("plugin config providers persist after instance dispose", async () => { const second = await WithInstance.provide({ directory: tmp.path, - fn: async () => list(), + fn: async (ctx) => list(ctx), }) expect(second[ProviderID.make("demo")]).toBeDefined() expect(second[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined() @@ -2672,10 +2696,10 @@ test("plugin config enabled and disabled providers are honored", async () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - set("ANTHROPIC_API_KEY", "test-anthropic-key") - set("OPENAI_API_KEY", "test-openai-key") - const providers = await list() + fn: async (ctx) => { + set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key") + set(ctx, "OPENAI_API_KEY", "test-openai-key") + const providers = await list(ctx) expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() }, @@ -2696,7 +2720,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () const none = await WithInstance.provide({ directory: base.path, - fn: async () => paid(await list()), + fn: async (ctx) => paid(await list(ctx)), }) await using keyed = await tmpdir({ @@ -2719,7 +2743,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () const keyedCount = await WithInstance.provide({ directory: keyed.path, - fn: async () => paid(await list()), + fn: async (ctx) => paid(await list(ctx)), }) expect(none).toBe(0) @@ -2740,7 +2764,7 @@ test("opencode loader keeps paid models when auth exists", async () => { const none = await WithInstance.provide({ directory: base.path, - fn: async () => paid(await list()), + fn: async (ctx) => paid(await list(ctx)), }) await using keyed = await tmpdir({ @@ -2774,7 +2798,7 @@ test("opencode loader keeps paid models when auth exists", async () => { const keyedCount = await WithInstance.provide({ directory: keyed.path, - fn: async () => paid(await list()), + fn: async (ctx) => paid(await list(ctx)), }) expect(none).toBe(0) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index ee3f6dc28a..a5841bd08d 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,7 +1,7 @@ import { afterEach, expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer, Queue } from "effect" import { Question } from "../../src/question" -import { Instance } from "../../src/project/instance" +import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceRuntime } from "../../src/project/instance-runtime" import { QuestionID } from "../../src/question/schema" import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" @@ -404,7 +404,10 @@ it.live("pending question rejects on instance dispose", () => }).pipe(provideInstance(dir), Effect.forkScoped) expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) - const ctx = yield* Effect.sync(() => Instance.current).pipe(provideInstance(dir)) + const ctx = yield* Effect.gen(function* () { + return yield* InstanceRef + }).pipe(provideInstance(dir)) + if (!ctx) return yield* Effect.die(new Error("missing test instance")) yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx)) const exit = yield* Fiber.await(fiber) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index 3f1d1e1140..fcf7b59ff3 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -1,11 +1,12 @@ import { afterEach, describe, expect, test } from "bun:test" import { Bus } from "../../src/bus" -import { Instance } from "../../src/project/instance" +import { AppRuntime } from "../../src/effect/app-runtime" +import { InstanceRef } from "../../src/effect/instance-ref" import { Server } from "../../src/server/server" import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" import { Event as ServerEvent } from "../../src/server/event" import * as Log from "@opencode-ai/core/util/log" -import { Schema } from "effect" +import { Effect, Schema } from "effect" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, reloadTestInstance, tmpdir } from "../fixture/fixture" @@ -108,7 +109,9 @@ describe("event HttpApi", () => { const next = readEvent(reader) const ctx = await reloadTestInstance({ directory: tmp.path }) - await Instance.restore(ctx, () => Bus.publish(ServerEvent.Connected, {})) + await AppRuntime.runPromise( + Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {})).pipe(Effect.provideService(InstanceRef, ctx)), + ) expect(await next).toMatchObject({ type: "server.connected", properties: {} }) } finally { diff --git a/packages/opencode/test/server/httpapi-exercise/runtime.ts b/packages/opencode/test/server/httpapi-exercise/runtime.ts index 12c3adc27f..7842752ad9 100644 --- a/packages/opencode/test/server/httpapi-exercise/runtime.ts +++ b/packages/opencode/test/server/httpapi-exercise/runtime.ts @@ -3,7 +3,6 @@ export type Runtime = { HttpApiApp: (typeof import("../../../src/server/routes/instance/httpapi/server"))["HttpApiApp"] AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"] InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"] - Instance: (typeof import("../../../src/project/instance"))["Instance"] InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"] Session: (typeof import("../../../src/session/session"))["Session"] Todo: (typeof import("../../../src/session/todo"))["Todo"] @@ -23,7 +22,6 @@ export function runtime() { const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server") const appRuntime = await import("../../../src/effect/app-runtime") const instanceRef = await import("../../../src/effect/instance-ref") - const instance = await import("../../../src/project/instance") const instanceStore = await import("../../../src/project/instance-store") const session = await import("../../../src/session/session") const todo = await import("../../../src/session/todo") @@ -37,7 +35,6 @@ export function runtime() { HttpApiApp: httpApiServer.HttpApiApp, AppLayer: appRuntime.AppLayer, InstanceRef: instanceRef.InstanceRef, - Instance: instance.Instance, InstanceStore: instanceStore.InstanceStore, Session: session.Session, Todo: todo.Todo, diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index 00a2d42b11..b2403b9fb2 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -3,7 +3,6 @@ import { Context } from "effect" import path from "path" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" -import { Instance } from "../../src/project/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 24a989f7cf..7c08d0e8d4 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -10,7 +10,6 @@ import { WorkspaceID } from "../../src/control-plane/schema" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { Instance } from "../../src/project/instance" import { InstanceLayer } from "../../src/project/instance-layer" import { Project } from "../../src/project/project" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 8bccbff863..0f10dbd3a7 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { PtyID } from "../../src/pty/schema" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import * as Log from "@opencode-ai/core/util/log" diff --git a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts index adf6e18ee8..7436c10817 100644 --- a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts +++ b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { ConfigProvider, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" -import { Instance } from "../../src/project/instance" import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index a2de1362fb..d34bb762ff 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -13,7 +13,6 @@ import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" @@ -71,7 +70,7 @@ function listedAdapter(directory: string, type: string): WorkspaceAdapter { }, async create() {}, async remove() {}, - list() { + list(context) { return [ { type, @@ -79,7 +78,7 @@ function listedAdapter(directory: string, type: string): WorkspaceAdapter { branch: "listed/main", directory, extra: { listed: true }, - projectID: Instance.project.id, + projectID: context?.instance?.project.id ?? missingAdapterContext(), }, ] }, @@ -92,6 +91,10 @@ function listedAdapter(directory: string, type: string): WorkspaceAdapter { } } +function missingAdapterContext(): never { + throw new Error("missing workspace adapter context") +} + function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter { return { name: "Remote Test", diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 3a949287e4..baeda4257c 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -4,8 +4,9 @@ import { tool, type ModelMessage } from "ai" import { Cause, Effect, Exit, Stream } from "effect" import z from "zod" import { makeRuntime } from "../../src/effect/run-service" +import { InstanceRef } from "../../src/effect/instance-ref" import { LLM } from "../../src/session/llm" -import { Instance } from "../../src/project/instance" +import type { InstanceContext } from "../../src/project/instance-context" import { WithInstance } from "../../src/project/with-instance" import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" @@ -18,19 +19,21 @@ import { 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) - }), - ) +async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) { + const effect = Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* provider.getModel(providerID, modelID) + }) + return AppRuntime.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx))) } const llm = makeRuntime(LLM.Service, LLM.defaultLayer) -async function drain(input: LLM.StreamInput) { - return llm.runPromise((svc) => svc.stream(input).pipe(Stream.runDrain)) +async function drain(input: LLM.StreamInput, ctx: InstanceContext) { + return llm.runPromise((svc) => { + const effect = svc.stream(input).pipe(Stream.runDrain) + return effect.pipe(Effect.provideService(InstanceRef, ctx)) + }) } describe("session.llm.hasToolCalls", () => { @@ -360,8 +363,8 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) + fn: async (ctx) => { + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx) const sessionID = SessionID.make("session-test-1") const agent = { name: "test", @@ -381,15 +384,18 @@ describe("session.llm.stream", () => { model: { providerID: ProviderID.make(providerID), modelID: resolved.id, variant: "high" }, } satisfies MessageV2.User - await drain({ - user, - sessionID, - model: resolved, - agent, - system: ["You are a helpful assistant."], - messages: [{ role: "user", content: "Hello" }], - tools: {}, - }) + await drain( + { + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }, + ctx, + ) const capture = await request const body = capture.body @@ -447,8 +453,8 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) + fn: async (ctx) => { + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx) const sessionID = SessionID.make("session-test-service-abort") const agent = { name: "test", @@ -478,7 +484,7 @@ describe("session.llm.stream", () => { messages: [{ role: "user", content: "Hello" }], tools: {}, }) - .pipe(Stream.runDrain), + .pipe(Stream.runDrain, Effect.provideService(InstanceRef, ctx)), { signal: ctrl.signal }, ) @@ -537,8 +543,8 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) + fn: async (ctx) => { + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx) const sessionID = SessionID.make("session-test-tools") const agent = { name: "test", @@ -557,22 +563,25 @@ describe("session.llm.stream", () => { tools: { question: true }, } satisfies MessageV2.User - await drain({ - user, - sessionID, - model: resolved, - agent, - permission: [{ permission: "question", pattern: "*", action: "allow" }], - system: ["You are a helpful assistant."], - messages: [{ role: "user", content: "Hello" }], - tools: { - question: tool({ - description: "Ask a question", - inputSchema: z.object({}), - execute: async () => ({ output: "" }), - }), + await drain( + { + user, + sessionID, + model: resolved, + agent, + permission: [{ permission: "question", pattern: "*", action: "allow" }], + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: { + question: tool({ + description: "Ask a question", + inputSchema: z.object({}), + execute: async () => ({ output: "" }), + }), + }, }, - }) + ctx, + ) const capture = await request const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined @@ -651,8 +660,8 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) + fn: async (ctx) => { + const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx) const sessionID = SessionID.make("session-test-2") const agent = { name: "test", @@ -671,15 +680,18 @@ describe("session.llm.stream", () => { model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" }, } satisfies MessageV2.User - await drain({ - user, - sessionID, - model: resolved, - agent, - system: ["You are a helpful assistant."], - messages: [{ role: "user", content: "Hello" }], - tools: {}, - }) + await drain( + { + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }, + ctx, + ) const capture = await request const body = capture.body @@ -767,8 +779,8 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) + fn: async (ctx) => { + const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx) const sessionID = SessionID.make("session-test-data-url") const agent = { name: "test", @@ -786,28 +798,31 @@ describe("session.llm.stream", () => { model: { providerID: ProviderID.make("openai"), modelID: resolved.id }, } satisfies MessageV2.User - await drain({ - user, - sessionID, - model: resolved, - agent, - system: ["You are a helpful assistant."], - messages: [ - { - role: "user", - content: [ - { type: "text", text: "Describe this image" }, - { - type: "file", - mediaType: "image/png", - filename: "large-image.png", - data: image, - }, - ], - }, - ] as ModelMessage[], - tools: {}, - }) + await drain( + { + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Describe this image" }, + { + type: "file", + mediaType: "image/png", + filename: "large-image.png", + data: image, + }, + ], + }, + ] as ModelMessage[], + tools: {}, + }, + ctx, + ) const capture = await request expect(capture.url.pathname.endsWith("/responses")).toBe(true) @@ -886,8 +901,8 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) + fn: async (ctx) => { + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx) const sessionID = SessionID.make("session-test-3") const agent = { name: "test", @@ -907,15 +922,18 @@ describe("session.llm.stream", () => { model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.5") }, } satisfies MessageV2.User - await drain({ - user, - sessionID, - model: resolved, - agent, - system: ["You are a helpful assistant."], - messages: [{ role: "user", content: "Hello" }], - tools: {}, - }) + await drain( + { + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }, + ctx, + ) const capture = await request const body = capture.body @@ -1004,8 +1022,8 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id)) + fn: async (ctx) => { + const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id), ctx) const sessionID = SessionID.make("session-test-anthropic-tools") const agent = { name: "test", @@ -1110,31 +1128,34 @@ describe("session.llm.stream", () => { }, ] as any[] - await drain({ - user, - sessionID, - model: resolved, - agent, - system: [], - messages: await MessageV2.toModelMessages(input as any, resolved), - tools: { - read: tool({ - description: "Stub read tool", - inputSchema: z.object({ - filePath: z.string(), + await drain( + { + user, + sessionID, + model: resolved, + agent, + system: [], + messages: await MessageV2.toModelMessages(input as any, resolved), + tools: { + read: tool({ + description: "Stub read tool", + inputSchema: z.object({ + filePath: z.string(), + }), + execute: async () => ({ output: "stub" }), }), - execute: async () => ({ output: "stub" }), - }), - glob: tool({ - description: "Stub glob tool", - inputSchema: z.object({ - pattern: z.string(), - path: z.string().optional(), + glob: tool({ + description: "Stub glob tool", + inputSchema: z.object({ + pattern: z.string(), + path: z.string().optional(), + }), + execute: async () => ({ output: "stub" }), }), - execute: async () => ({ output: "stub" }), - }), + }, }, - }) + ctx, + ) const capture = await request const body = capture.body @@ -1245,8 +1266,8 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) + fn: async (ctx) => { + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx) const sessionID = SessionID.make("session-test-4") const agent = { name: "test", @@ -1266,15 +1287,18 @@ describe("session.llm.stream", () => { model: { providerID: ProviderID.make(providerID), modelID: resolved.id }, } satisfies MessageV2.User - await drain({ - user, - sessionID, - model: resolved, - agent, - system: ["You are a helpful assistant."], - messages: [{ role: "user", content: "Hello" }], - tools: {}, - }) + await drain( + { + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }, + ctx, + ) const capture = await request const body = capture.body diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 04ef5c5d01..e59caaa720 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -48,7 +48,7 @@ describe("tool.assertExternalDirectory", () => { }), ) - it.live("no-ops for paths inside Instance.directory", () => + it.live("no-ops for paths inside the instance directory", () => provideInstance("/tmp/project")( Effect.gen(function* () { const { requests, ctx } = makeCtx() diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index 875af8e010..875edc1c05 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -6,7 +6,6 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { LSP } from "@/lsp/lsp" import { Permission } from "../../src/permission" -import { Instance } from "../../src/project/instance" import { MessageID, SessionID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { Truncate } from "@/tool/truncate" diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index fcbd10bb4d..bbfc4c4843 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -10,7 +10,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { Git } from "@/git" import { LSP } from "@/lsp/lsp" import { Permission } from "../../src/permission" -import { Instance } from "../../src/project/instance" import { SessionID, MessageID } from "../../src/session/schema" import { Instruction } from "../../src/session/instruction" import { ReadTool } from "../../src/tool/read" diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index c58d1a190d..d1538756e1 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -5,7 +5,6 @@ import path from "path" import { pathToFileURL } from "url" import type { Permission } from "../../src/permission" import type { Tool } from "@/tool/tool" -import { Instance } from "../../src/project/instance" import { SkillTool } from "../../src/tool/skill" import { ToolRegistry } from "@/tool/registry" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index f6ac57a8ce..8a5545e717 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -3,7 +3,6 @@ import { Effect, Layer } from "effect" import path from "path" import fs from "fs/promises" import { WriteTool } from "../../src/tool/write" -import { Instance } from "../../src/project/instance" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Bus } from "../../src/bus"