refactor(instance): remove legacy runtime fallback (#27757)

This commit is contained in:
Shoubhit Dash
2026-05-15 23:05:44 +05:30
committed by GitHub
parent 9975c1ed1c
commit 0c9cfe923f
47 changed files with 937 additions and 1254 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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<string, Promise<InstanceContext>>`
- 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<string, Promise<InstanceContext>>`
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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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 }

View File

@@ -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 = <Args, A>(opts: EffectCmdOpts<Args, A>) =>
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))
}

View File

@@ -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 = <A, E, R>(effect: Effect.Effect<A, E, R>, 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) {

View File

@@ -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<WorkspaceInfo>
create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
list?(): WorkspaceListedInfo[] | Promise<WorkspaceListedInfo[]>
remove(info: WorkspaceInfo): Promise<void>
target(info: WorkspaceInfo): Target | Promise<Target>
configure(info: WorkspaceInfo, context?: WorkspaceAdapterContext): WorkspaceInfo | Promise<WorkspaceInfo>
create(
info: WorkspaceInfo,
env: Record<string, string | undefined>,
from?: WorkspaceInfo,
context?: WorkspaceAdapterContext,
): Promise<void>
list?(context?: WorkspaceAdapterContext): WorkspaceListedInfo[] | Promise<WorkspaceListedInfo[]>
remove(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Promise<void>
target(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Target | Promise<Target>
}

View File

@@ -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<typeof getAdapter>, info: WorkspaceInfo) =>
Effect.gen(function* () {
const context = yield* adapterContext
return yield* EffectBridge.fromPromise(() => adapter.configure(info, context))
})
const adapterCreate = (
adapter: ReturnType<typeof getAdapter>,
info: WorkspaceInfo,
env: Record<string, string | undefined>,
from?: WorkspaceInfo,
) =>
Effect.gen(function* () {
const context = yield* adapterContext
return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, context))
})
const adapterList = (adapter: ReturnType<typeof getAdapter>) =>
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(() => {

View File

@@ -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: <Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) => (...args: Args) => Result
}
function restore<R>(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<R>(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 = <Args extends readonly unknown[], Result>(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 = <T>(fn: () => Promise<T> | T): Effect.Effect<T> =>
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<Shape> {
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 = <A, E, R>(effect: Effect.Effect<A, E, R>) => attachWith(effect, { instance, workspace })
const wrap = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
attach(effect).pipe(Effect.provide(ctx)) as Effect.Effect<A, E, never>
attachWith(effect.pipe(Effect.provide(ctx)) as Effect.Effect<A, E, never>, { instance, workspace })
return {
promise: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
restore(instance, workspace, () => Effect.runPromise(wrap(effect))),
restoreWorkspace(workspace, () => Effect.runPromise(wrap(effect))),
fork: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
restore(instance, workspace, () => Effect.runFork(wrap(effect))),
restoreWorkspace(workspace, () => Effect.runFork(wrap(effect))),
run: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.callback<A, E>((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<Shape> {
bind:
<Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) =>
(...args: Args) =>
restore(instance, workspace, () => fn(...args)),
restoreWorkspace(workspace, () => Effect.runSync(wrap(Effect.sync(() => fn(...args))))),
} satisfies Shape
})
}

View File

@@ -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<A, E = never, R = never> {
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
}
export const bind = <F extends (...args: any[]) => 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* () {

View File

@@ -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<A, E, R>(effect: Effect.Effect<A, E, R>, refs: Refs):
export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
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),
})
}

View File

@@ -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<Awaited<ReturnType<typeof create>>>
@@ -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()
})
}

View File

@@ -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)

View File

@@ -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<F extends (...args: any[]) => 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<R>(ctx: InstanceContext, fn: () => R): R {
return context.provide(ctx, fn)
},
}

View File

@@ -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<R>(input: { directory: string; fn: () => R }): Promise<R> {
export async function provide<R>(input: { directory: string; fn: (ctx: InstanceContext) => R }): Promise<R> {
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"

View File

@@ -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<E>(
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
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),
)
})
}

View File

@@ -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)
}),
)

View File

@@ -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.

View File

@@ -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 },
)

View File

@@ -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 = <A, E, R>(effect: Effect.Effect<A, E, R>, 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<string, unknown>).theme).toBeUndefined()
expect((config as Record<string, unknown>).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")
},
})

View File

@@ -139,7 +139,18 @@ async function initGitRepo(dir: string) {
await $`git commit -m "base"`.cwd(dir).quiet()
}
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, Workspace.Service>) => AppRuntime.runPromise(effect)
function currentInstance() {
try {
return context.use()
} catch {
return undefined
}
}
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, Workspace.Service>) => {
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(

View File

@@ -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"

View File

@@ -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 = <A>(fn: (store: InstanceStore.Interface) => Effect.Effect<A>) =>
testInstanceRuntime.runPromise(InstanceStore.Service.use(fn))
export async function provideTestInstance<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) {
export async function provideTestInstance<R>(input: {
directory: string
init?: Effect.Effect<void>
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<R>) =>
Effect.promise<A>(async () => {
const ctx = await runTestInstanceStore((store) => store.load({ directory }))
return Instance.restore(ctx, () =>
Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))),
)
return Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx)))
}),
)

View File

@@ -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", {

View File

@@ -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)

View File

@@ -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<void> = Effect.void
@@ -37,7 +36,7 @@ const registerDisposerScoped = (disposer: (directory: string) => Promise<void>)
)
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 },
)
})

View File

@@ -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}`))

View File

@@ -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<string, string | undefined>()
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()

View File

@@ -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"

View File

@@ -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<string, string | undefined>()
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<RuntimeFlags.Info> = {}) =>
Provider.layer.pipe(
@@ -36,41 +60,41 @@ const providerLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.provide(RuntimeFlags.layer(flags)),
)
async function run<A, E>(fn: (provider: Provider.Interface) => Effect.Effect<A, E, never>) {
async function run<A, E>(ctx: InstanceContext, fn: (provider: Provider.Interface) => Effect.Effect<A, E, never>) {
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)

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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",

View File

@@ -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

View File

@@ -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()

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"