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
+3 -12
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. 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`. Plain async code should pass explicit context or stay inside an Effect fiber; do not add ambient instance context shims.
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)
```
@@ -6,7 +6,6 @@ Current status on this branch:
- `src/` has 5 `makeRuntime(...)` call sites total. - `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`. - 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`. - 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: 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/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/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 ## 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` 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` 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 ## Checklist
+3 -7
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 [`EffectBridge`](../../src/effect/bridge.ts) is the sanctioned helper for
Promise/callback interop that needs to preserve instance/workspace context. Promise/callback interop that needs to preserve instance/workspace context.
Keep it, but reduce its dependency on legacy `Instance.current` / It preserves explicit `InstanceRef` / `WorkspaceRef` context for effects run
`Instance.restore` over time. through the bridge. Plain JS callbacks that need instance data should receive
that data explicitly.
`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.
## Testing ## Testing
@@ -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(...)` Do not add new ambient instance globals. Promise and callback boundaries should either stay in Effect, use `EffectBridge`, or pass the required context explicitly.
- 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`
@@ -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.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. - [ ] `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 ## Notes
- Prefer small, semantics-preserving config migrations. Config precedence, legacy key migration, and plugin origin tracking are easy to break accidentally. - Prefer small, semantics-preserving config migrations. Config precedence, legacy key migration, and plugin origin tracking are easy to break accidentally.
+8 -71
View File
@@ -64,13 +64,11 @@ P6 OA
explicit and testable instead of mutable module state. explicit and testable instead of mutable module state.
Shrinks: [`global.ts`](../../../core/src/global.ts) import-time side Shrinks: [`global.ts`](../../../core/src/global.ts) import-time side
effects, mutable `Global.Path` overrides, and its `Flag` dependency. effects, mutable `Global.Path` overrides, and its `Flag` dependency.
- `INST` Instance shim — remove ambient `Instance` usage and old ALS - `INST` Instance context — keep project context explicit through Effect refs
access patterns. and bridge boundaries.
Shrinks: [`src/project/instance.ts`](../../src/project/instance.ts).
- `BRIDGE` Promise/callback interop — keep bridge helpers, but reduce - `BRIDGE` Promise/callback interop — keep bridge helpers, but reduce
legacy ALS coupling. legacy ALS coupling.
Shrinks: [`src/effect/bridge.ts`](../../src/effect/bridge.ts) Shrinks: ad hoc Promise/callback re-entry code.
dependency on [`project/instance.ts`](../../src/project/instance.ts).
- `PROC` AppProcess migration — prefer `AppProcess.Service` over raw - `PROC` AppProcess migration — prefer `AppProcess.Service` over raw
process wrappers. process wrappers.
Shrinks: direct spawn callsites and legacy process helpers. Shrinks: direct spawn callsites and legacy process helpers.
@@ -221,74 +219,13 @@ Next PR candidates:
## P4: Instance And Bridge ## P4: Instance And Bridge
[`project/instance.ts`](../../src/project/instance.ts) is the deletion Instance context migration is complete for the legacy sync shim. Promise and callback interop continues through [`effect/bridge.ts`](../../src/effect/bridge.ts).
target. [`effect/bridge.ts`](../../src/effect/bridge.ts) is not a near-term
deletion target; Promise/callback interop will continue to exist.
Goal: Current rules:
- Keep a sanctioned bridge for Promise/callback boundaries. - Effect services read instance data from `InstanceRef`, `WorkspaceRef`, `InstanceState`, or explicit arguments.
- Reduce bridge dependence on legacy `Instance.restore` / `Instance.current`. - Plain JavaScript callback boundaries use `EffectBridge` or explicit context arguments.
- Move callers toward `InstanceRef`, `WorkspaceRef`, `InstanceState`, or - Runtime entrypoints must provide refs explicitly when they are instance-scoped.
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).
## Lower Priority Tracks ## Lower Priority Tracks
+12 -4
View File
@@ -43,16 +43,25 @@ import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema" import { ModelID, ProviderID } from "../provider/schema"
import { Agent as AgentModule } from "../agent/agent" import { Agent as AgentModule } from "../agent/agent"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceRuntime } from "@/project/instance-runtime"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { MessageV2 } from "@/session/message-v2" import { MessageV2 } from "@/session/message-v2"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { ConfigMCP } from "@/config/mcp" import { ConfigMCP } from "@/config/mcp"
import { Todo } from "@/session/todo" import { Todo } from "@/session/todo"
import { Result, Schema } from "effect" import { Effect, Result, Schema } from "effect"
import { LoadAPIKeyError } from "ai" import { LoadAPIKeyError } from "ai"
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff" import { applyPatch } from "diff"
import { InstallationVersion } from "@opencode-ai/core/installation/version" 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" import { ShellID } from "@/tool/shell/id"
type ModeOption = { id: string; name: string; description?: string } type ModeOption = { id: string; name: string; description?: string }
@@ -1094,7 +1103,7 @@ export class Agent implements ACPAgent {
const currentModeId = await (async () => { const currentModeId = await (async () => {
if (!availableModes.length) return undefined 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 const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id
this.sessionManager.setMode(sessionId, resolvedModeId) this.sessionManager.setMode(sessionId, resolvedModeId)
return resolvedModeId return resolvedModeId
@@ -1328,8 +1337,7 @@ export class Agent implements ACPAgent {
if (!current) { if (!current) {
this.sessionManager.setModel(session.id, model) this.sessionManager.setModel(session.id, model)
} }
const agent = const agent = session.modeId ?? (await defaultAgentInfo(directory)).name
session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name
const parts: Array< const parts: Array<
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
+1 -10
View File
@@ -3,7 +3,6 @@ import { Effect, Schema } from "effect"
import { AppRuntime, type AppServices } from "@/effect/app-runtime" import { AppRuntime, type AppServices } from "@/effect/app-runtime"
import { InstanceStore } from "@/project/instance-store" import { InstanceStore } from "@/project/instance-store"
import { InstanceRef } from "@/effect/instance-ref" import { InstanceRef } from "@/effect/instance-ref"
import { Instance } from "@/project/instance"
import { cmd, type WithDoubleDash } from "./cmd/cmd" import { cmd, type WithDoubleDash } from "./cmd/cmd"
/** /**
@@ -83,19 +82,11 @@ export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>
return return
} }
const directory = opts.directory?.(args) ?? process.cwd() 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( const { store, ctx } = await AppRuntime.runPromise(
InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))), InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),
) )
try { try {
await Instance.restore(ctx, () => await AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx)))
AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))),
)
} finally { } finally {
await AppRuntime.runPromise(store.dispose(ctx)) await AppRuntime.runPromise(store.dispose(ctx))
} }
@@ -1,7 +1,6 @@
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { WorkspaceContext } from "../workspace-context" import { type WorkspaceAdapter, type WorkspaceAdapterContext, WorkspaceInfo } from "../types"
import { type WorkspaceAdapter, WorkspaceInfo } from "../types"
const WorktreeConfig = Schema.Struct({ const WorktreeConfig = Schema.Struct({
name: WorkspaceInfo.fields.name, name: WorkspaceInfo.fields.name,
@@ -11,26 +10,31 @@ const WorktreeConfig = Schema.Struct({
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig) const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
async function loadWorktree() { async function loadWorktree() {
const [{ AppRuntime }, { Instance }, { Worktree }] = await Promise.all([ const [{ AppRuntime }, { Worktree }] = await Promise.all([
import("@/effect/app-runtime"), import("@/effect/app-runtime"),
import("@/project/instance"),
import("@/worktree"), 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 = { export const WorktreeAdapter: WorkspaceAdapter = {
name: "Worktree", name: "Worktree",
description: "Create a git worktree", description: "Create a git worktree",
async configure(info) { async configure(info, context) {
const { AppRuntime, Instance, Worktree } = await loadWorktree() const { AppRuntime, Worktree } = await loadWorktree()
const ctx = Instance.current
const workspaceID = WorkspaceContext.workspaceID
const next = await AppRuntime.runPromise( const next = await AppRuntime.runPromise(
Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })).pipe( provideContext(Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })), context),
Effect.provideService(InstanceRef, ctx),
Effect.provideService(WorkspaceRef, workspaceID),
),
) )
return { return {
...info, ...info,
@@ -38,32 +42,27 @@ export const WorktreeAdapter: WorkspaceAdapter = {
directory: next.directory, directory: next.directory,
} }
}, },
async create(info) { async create(info, _env, _from, context) {
const { AppRuntime, Instance, Worktree } = await loadWorktree() const { AppRuntime, Worktree } = await loadWorktree()
const ctx = Instance.current
const workspaceID = WorkspaceContext.workspaceID
const config = decodeWorktreeConfig(info) const config = decodeWorktreeConfig(info)
await AppRuntime.runPromise( await AppRuntime.runPromise(
Worktree.Service.use((svc) => provideContext(
svc.createFromInfo({ Worktree.Service.use((svc) =>
name: config.name, svc.createFromInfo({
directory: config.directory, name: config.name,
...(config.branch ? { branch: config.branch } : {}), directory: config.directory,
}), ...(config.branch ? { branch: config.branch } : {}),
).pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID)), }),
),
context,
),
) )
}, },
async list() { async list(context) {
const { AppRuntime, Instance, Worktree } = await loadWorktree() const { AppRuntime, Worktree } = await loadWorktree()
const ctx = Instance.current const ctx = requireInstance(context)
const workspaceID = WorkspaceContext.workspaceID
return ( return (
await AppRuntime.runPromise( await AppRuntime.runPromise(provideContext(Worktree.Service.use((svc) => svc.list()), context))
Worktree.Service.use((svc) => svc.list()).pipe(
Effect.provideService(InstanceRef, ctx),
Effect.provideService(WorkspaceRef, workspaceID),
),
)
).map((info) => ({ ).map((info) => ({
type: "worktree", type: "worktree",
name: info.name, name: info.name,
@@ -72,16 +71,11 @@ export const WorktreeAdapter: WorkspaceAdapter = {
projectID: ctx.project.id, projectID: ctx.project.id,
})) }))
}, },
async remove(info) { async remove(info, context) {
const { AppRuntime, Instance, Worktree } = await loadWorktree() const { AppRuntime, Worktree } = await loadWorktree()
const ctx = Instance.current
const workspaceID = WorkspaceContext.workspaceID
const config = decodeWorktreeConfig(info) const config = decodeWorktreeConfig(info)
await AppRuntime.runPromise( await AppRuntime.runPromise(
Worktree.Service.use((svc) => svc.remove({ directory: config.directory })).pipe( provideContext(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })), context),
Effect.provideService(InstanceRef, ctx),
Effect.provideService(WorkspaceRef, workspaceID),
),
) )
}, },
target(info) { target(info) {
+16 -5
View File
@@ -1,5 +1,6 @@
import { Schema, Struct } from "effect" import { Schema, Struct } from "effect"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
import type { InstanceContext } from "@/project/instance-context"
import { WorkspaceID } from "./schema" import { WorkspaceID } from "./schema"
import type { DeepMutable } from "@opencode-ai/core/schema" import type { DeepMutable } from "@opencode-ai/core/schema"
@@ -37,12 +38,22 @@ export type Target =
headers?: HeadersInit headers?: HeadersInit
} }
export type WorkspaceAdapterContext = {
readonly instance?: InstanceContext
readonly workspaceID?: WorkspaceID
}
export type WorkspaceAdapter = { export type WorkspaceAdapter = {
name: string name: string
description: string description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo> configure(info: WorkspaceInfo, context?: WorkspaceAdapterContext): WorkspaceInfo | Promise<WorkspaceInfo>
create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void> create(
list?(): WorkspaceListedInfo[] | Promise<WorkspaceListedInfo[]> info: WorkspaceInfo,
remove(info: WorkspaceInfo): Promise<void> env: Record<string, string | undefined>,
target(info: WorkspaceInfo): Target | Promise<Target> 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>
} }
@@ -26,8 +26,8 @@ import { SessionID } from "@/session/schema"
import { NotFoundError } from "@/storage/storage" import { NotFoundError } from "@/storage/storage"
import { errorData } from "@/util/error" import { errorData } from "@/util/error"
import { waitEvent } from "./util" import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context"
import { EffectBridge } from "@/effect/bridge" import { EffectBridge } from "@/effect/bridge"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { Vcs } from "@/project/vcs" import { Vcs } from "@/project/vcs"
import { InstanceStore } from "@/project/instance-store" import { InstanceStore } from "@/project/instance-store"
import { InstanceBootstrap } from "@/project/bootstrap" 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* ( const connectSSE = Effect.fn("Workspace.connectSSE")(function* (
url: URL | string, url: URL | string,
headers: HeadersInit | undefined, headers: HeadersInit | undefined,
@@ -281,8 +325,7 @@ export const layer = Layer.effect(
const workspace = yield* get(input.workspaceID) const workspace = yield* get(input.workspaceID)
if (!workspace) return input.fallback if (!workspace) return input.fallback
const adapter = getAdapter(workspace.projectID, workspace.type) const target = yield* adapterTarget(workspace)
const target = yield* EffectBridge.fromPromise(() => adapter.target(workspace))
if (target.type === "local") { if (target.type === "local") {
const store = yield* InstanceStore.Service const store = yield* InstanceStore.Service
@@ -375,35 +418,27 @@ export const layer = Layer.effect(
events: events.length, events: events.length,
}) })
yield* Effect.promise(async () => { yield* Effect.forEach(
await WorkspaceContext.provide({ events,
workspaceID: space.id, (event) =>
async fn() { sync
await Effect.runPromise( .replay(
Effect.forEach( {
events, id: event.id,
(event) => aggregateID: event.aggregate_id,
sync.replay( seq: event.seq,
{ type: event.type,
id: event.id, data: event.data,
aggregateID: event.aggregate_id, },
seq: event.seq, { publish: true },
type: event.type,
data: event.data,
},
{ publish: true },
),
{ discard: true },
),
) )
}, .pipe(Effect.provideService(WorkspaceRef, space.id)),
}) { discard: true },
}) )
}) })
const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) { const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) {
const adapter = getAdapter(space.projectID, space.type) const target = yield* adapterTarget(space)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
if (target.type === "local") return if (target.type === "local") return
@@ -486,8 +521,7 @@ export const layer = Layer.effect(
const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) { const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) {
if (!flags.experimentalWorkspaces) return if (!flags.experimentalWorkspaces) return
const adapter = getAdapter(space.projectID, space.type) const target = yield* adapterTarget(space).pipe(
const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe(
Effect.catch((error) => Effect.catch((error) =>
Effect.sync(() => { Effect.sync(() => {
setStatus(space.id, "error") setStatus(space.id, "error")
@@ -538,15 +572,13 @@ export const layer = Layer.effect(
const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
const id = WorkspaceID.ascending(input.id) const id = WorkspaceID.ascending(input.id)
const adapter = getAdapter(input.projectID, input.type) const adapter = getAdapter(input.projectID, input.type)
const config = yield* EffectBridge.fromPromise(() => const config = yield* adapterConfigure(adapter, {
adapter.configure({ ...input,
...input, id,
id, name: Slug.create(),
name: Slug.create(), directory: null,
directory: null, extra: input.extra ?? null,
extra: input.extra ?? null, })
}),
)
const info: Info = { const info: Info = {
id, id,
@@ -583,7 +615,7 @@ export const layer = Layer.effect(
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
} }
yield* EffectBridge.fromPromise(() => adapter.create(config, env)) yield* adapterCreate(adapter, config, env)
yield* Effect.all( yield* Effect.all(
[ [
waitEvent({ waitEvent({
@@ -622,8 +654,7 @@ export const layer = Layer.effect(
if (current?.workspaceID) { if (current?.workspaceID) {
const previous = yield* get(current.workspaceID) const previous = yield* get(current.workspaceID)
if (previous) { if (previous) {
const adapter = getAdapter(previous.projectID, previous.type) const target = yield* adapterTarget(previous)
const target = yield* EffectBridge.fromPromise(() => adapter.target(previous))
if (target.type === "remote") { if (target.type === "remote") {
yield* syncHistory(previous, target.url, target.headers).pipe( yield* syncHistory(previous, target.url, target.headers).pipe(
@@ -701,8 +732,7 @@ export const layer = Layer.effect(
workspaceID, workspaceID,
}) })
const adapter = getAdapter(space.projectID, space.type) const target = yield* adapterTarget(space)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
if (target.type === "local") { if (target.type === "local") {
yield* sync.run(Session.Event.Updated, { yield* sync.run(Session.Event.Updated, {
@@ -856,7 +886,7 @@ export const layer = Layer.effect(
registeredAdapters(project.id), registeredAdapters(project.id),
([type, adapter]) => ([type, adapter]) =>
adapter.list adapter.list
? EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.() ?? [])).pipe( ? adapterList(adapter).pipe(
Effect.catchCause((error) => Effect.catchCause((error) =>
Effect.sync(() => { Effect.sync(() => {
log.warn("workspace adapter list failed", { type, error }) log.warn("workspace adapter list failed", { type, error })
@@ -937,8 +967,7 @@ export const layer = Layer.effect(
const info = fromRow(row) const info = fromRow(row)
yield* Effect.catchCause( yield* Effect.catchCause(
Effect.gen(function* () { Effect.gen(function* () {
const adapter = getAdapter(info.projectID, row.type) yield* adapterRemove(info, row.type)
yield* EffectBridge.fromPromise(() => adapter.remove(info))
}), }),
() => () =>
Effect.sync(() => { Effect.sync(() => {
+18 -38
View File
@@ -1,9 +1,6 @@
import { Context, Effect, Exit, Fiber } from "effect" import { Context, Effect, Exit, Fiber } from "effect"
import { WorkspaceContext } from "@/control-plane/workspace-context" 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 type { WorkspaceID } from "@/control-plane/schema"
import { LocalContext } from "@/util/local-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref" import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { attachWith } from "./run-service" 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 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 { function restoreWorkspace<R>(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)
if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn) if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn)
return fn() return fn()
} }
function captureSync() { function captureSync() {
const fiber = Fiber.getCurrent() const fiber = Fiber.getCurrent()
const value = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined const instance = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined
const instance =
value ??
(() => {
try {
return Instance.current
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
})()
const workspace = const workspace =
(fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined) ?? WorkspaceContext.workspaceID (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined) ?? WorkspaceContext.workspaceID
return { instance, workspace } return { instance, workspace }
@@ -42,47 +26,43 @@ function captureSync() {
export const bind = <Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) => { export const bind = <Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) => {
const captured = captureSync() 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 * Bridge from Effect into a Promise-returning JS callback while preserving
* legacy `Instance.context` and `WorkspaceContext` AsyncLocalStorage for * `WorkspaceContext` AsyncLocalStorage for callback code that still reads it.
* the duration of the callback. Effect's `InstanceRef`/`WorkspaceRef` do * `InstanceRef` is captured for effects run through the returned bridge APIs;
* not propagate across async/await boundaries inside `Effect.promise(() => * plain JS callbacks that need it should receive the ref explicitly.
* 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.).
* *
* 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> => export const fromPromise = <T>(fn: () => Promise<T> | T): Effect.Effect<T> =>
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceRef
const workspace = yield* WorkspaceRef 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> { export function make(): Effect.Effect<Shape> {
return Effect.gen(function* () { return Effect.gen(function* () {
const ctx = yield* Effect.context() const ctx = yield* Effect.context()
const value = yield* InstanceRef
const captured = captureSync() const captured = captureSync()
const instance = value ?? captured.instance const instance = (yield* InstanceRef) ?? captured.instance
const workspace = (yield* WorkspaceRef) ?? captured.workspace 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>) => 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 { return {
promise: <A, E, R>(effect: Effect.Effect<A, E, R>) => 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>) => 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>) => run: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.callback<A, E>((resume) => { Effect.callback<A, E>((resume) => {
restore(instance, workspace, () => restoreWorkspace(workspace, () =>
Effect.runPromiseExit(wrap(effect)).then((exit) => Effect.runPromiseExit(wrap(effect)).then((exit) =>
resume(Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause)), resume(Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause)),
), ),
@@ -91,7 +71,7 @@ export function make(): Effect.Effect<Shape> {
bind: bind:
<Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) => <Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) =>
(...args: Args) => (...args: Args) =>
restore(instance, workspace, () => fn(...args)), restoreWorkspace(workspace, () => Effect.runSync(wrap(Effect.sync(() => fn(...args))))),
} satisfies Shape } satisfies Shape
}) })
} }
+4 -16
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 * as EffectLogger from "@opencode-ai/core/effect/logger"
import { Instance } from "@/project/instance"
import type { InstanceContext } from "@/project/instance-context" import type { InstanceContext } from "@/project/instance-context"
import { LocalContext } from "@/util/local-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref" import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { registerDisposer } from "./instance-registry" import { registerDisposer } from "./instance-registry"
import { WorkspaceContext } from "@/control-plane/workspace-context" 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> 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* () { 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* () { export const workspaceID = Effect.gen(function* () {
+1 -11
View File
@@ -1,7 +1,5 @@
import { Effect, Fiber, Layer, ManagedRuntime } from "effect" import { Effect, Fiber, Layer, ManagedRuntime } from "effect"
import * as Context from "effect/Context" import * as Context from "effect/Context"
import { Instance } from "@/project/instance"
import { LocalContext } from "@/util/local-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref" import { InstanceRef, WorkspaceRef } from "./instance-ref"
import * as Observability from "@opencode-ai/core/effect/observability" import * as Observability from "@opencode-ai/core/effect/observability"
import { WorkspaceContext } from "@/control-plane/workspace-context" 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> { export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
const workspace = WorkspaceContext.workspaceID 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() const fiber = Fiber.getCurrent()
return attachWith(effect, { 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), workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined),
}) })
} }
+26 -7
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 * as Log from "@opencode-ai/core/util/log"
import { Process } from "@/util/process" import { Process } from "@/util/process"
import { LANGUAGE_EXTENSIONS } from "./language" import { LANGUAGE_EXTENSIONS } from "./language"
import { Schema } from "effect" import { Effect, Schema } from "effect"
import type * as LSPServer from "./server" import type * as LSPServer from "./server"
import { withTimeout } from "../util/timeout" import { withTimeout } from "../util/timeout"
import { Filesystem } from "@/util/filesystem" 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_DEBOUNCE_MS = 150
const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000 const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000
@@ -25,6 +28,7 @@ const FILE_CHANGE_CHANGED = 2
const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2 const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2
const log = Log.create({ service: "lsp.client" }) const log = Log.create({ service: "lsp.client" })
const busRuntime = makeRuntime(Bus.Service, Bus.layer)
export type Info = NonNullable<Awaited<ReturnType<typeof create>>> export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
@@ -134,9 +138,16 @@ function shouldSeedDiagnosticsOnFirstPush(serverID: string) {
return serverID === "typescript" 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) const logger = log.clone().tag("serverID", input.serverID)
logger.info("starting client") logger.info("starting client")
const instance = input.instance ?? context.use()
const connection = createMessageConnection( const connection = createMessageConnection(
new StreamMessageReader(input.server.process.stdout as any), 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) ?? [])]) dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])])
const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => { const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {
pushDiagnostics.set(filePath, next) 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[]) => { const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {
pullDiagnostics.set(filePath, next) pullDiagnostics.set(filePath, next)
@@ -510,10 +525,14 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
} }
timeoutTimer = setTimeout(() => finish(false), request.timeout) timeoutTimer = setTimeout(() => finish(false), request.timeout)
unsub = Bus.subscribe(Event.Diagnostics, (event) => { unsub = busRuntime.runSync((svc) =>
if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return svc
schedule() .subscribeCallback(Event.Diagnostics, (event) => {
}) if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return
schedule()
})
.pipe(Effect.provideService(InstanceRef, instance)),
)
schedule() schedule()
}) })
} }
+1
View File
@@ -237,6 +237,7 @@ export const layer = Layer.effect(
server: handle, server: handle,
root, root,
directory: ctx.directory, directory: ctx.directory,
instance: ctx,
}).catch(async (err) => { }).catch(async (err) => {
s.broken.add(key) s.broken.add(key)
await Process.stop(handle.process) await Process.stop(handle.process)
-36
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)
},
}
@@ -1,12 +1,12 @@
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { context } from "./instance-context" import type { InstanceContext } from "./instance-context"
import { InstanceStore } from "./instance-store" 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( const ctx = await AppRuntime.runPromise(
InstanceStore.Service.use((store) => store.load({ directory: input.directory })), 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" export * as WithInstance from "./with-instance"
@@ -1,4 +1,4 @@
import { WorkspaceRef } from "@/effect/instance-ref" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store" import { InstanceStore } from "@/project/instance-store"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
@@ -26,9 +26,10 @@ function provideInstanceContext<E>(
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> { ): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
return Effect.gen(function* () { return Effect.gen(function* () {
const route = yield* WorkspaceRouteContext const route = yield* WorkspaceRouteContext
return yield* store.provide( const ctx = yield* store.load({ directory: decode(route.directory) })
{ directory: decode(route.directory) }, return yield* effect.pipe(
effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)), Effect.provideService(InstanceRef, ctx),
Effect.provideService(WorkspaceRef, route.workspaceID),
) )
}) })
} }
+5 -5
View File
@@ -609,10 +609,10 @@ export const ShellTool = Tool.define(
parameters: prompt.parameters, parameters: prompt.parameters,
execute: (params: Parameters, ctx: Tool.Context) => execute: (params: Parameters, ctx: Tool.Context) =>
Effect.gen(function* () { Effect.gen(function* () {
const executeInstance = yield* InstanceState.context const instanceCtx = yield* InstanceState.context
const cwd = params.workdir const cwd = params.workdir
? yield* resolvePath(params.workdir, executeInstance.directory, shell) ? yield* resolvePath(params.workdir, instanceCtx.directory, shell)
: executeInstance.directory : instanceCtx.directory
if (params.timeout !== undefined && params.timeout < 0) { if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) 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) => const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) =>
Effect.sync(() => tree.delete()), Effect.sync(() => tree.delete()),
) )
const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance) const scan = yield* collect(tree.rootNode, cwd, ps, shell, instanceCtx)
if (!containsPath(cwd, executeInstance)) scan.dirs.add(cwd) if (!containsPath(cwd, instanceCtx)) scan.dirs.add(cwd)
yield* ask(ctx, scan) yield* ask(ctx, scan)
}), }),
) )
+1 -1
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. 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. - `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. - `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. - `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server.
@@ -3,7 +3,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect } from "effect" import { Effect } from "effect"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -13,46 +12,28 @@ afterEach(async () => {
await disposeAllInstances() await disposeAllInstances()
}) })
// Regression for PR #25522: when an effectCmd handler does it.live("effect-cmd.ts does not restore legacy instance ALS", () =>
// `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", () =>
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* AppFileSystem.Service const fs = yield* AppFileSystem.Service
const source = yield* fs.readFileString(fileURLToPath(new URL("../../src/cli/effect-cmd.ts", import.meta.url))) 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( it.instance(
"Instance.current reachable after await inside restored Effect.promise(async)", "InstanceRef remains the handler context across Effect promise awaits",
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
const ctx = yield* InstanceRef const ctx = yield* InstanceRef
if (!ctx) throw new Error("InstanceRef not provided") if (!ctx) throw new Error("InstanceRef not provided")
const current = yield* Effect.promise(() => const directory = yield* Effect.promise(async () => {
Instance.restore(ctx, async () => { await Promise.resolve()
await Promise.resolve() return ctx.directory
try { })
return Instance.current
} catch {
return undefined
}
}),
)
expect(current?.directory).toBe(test.directory) expect(directory).toBe(test.directory)
}), }),
{ git: true }, { git: true },
) )
+163 -133
View File
@@ -6,7 +6,8 @@ import { ConfigManaged } from "@/config/managed"
import { ConfigParse } from "../../src/config/parse" import { ConfigParse } from "../../src/config/parse"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock" 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 { WithInstance } from "../../src/project/with-instance"
import { Auth } from "../../src/auth" import { Auth } from "../../src/auth"
import { Account } from "../../src/account/account" import { Account } from "../../src/account/account"
@@ -61,9 +62,20 @@ const layer = Config.layer.pipe(
const it = testEffect(layer) const it = testEffect(layer)
const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer))) const provideCurrentInstance = <A, E, R>(effect: Effect.Effect<A, E, R>, ctx: InstanceContext) =>
const save = (config: Config.Info) => effect.pipe(Effect.provideService(InstanceRef, ctx))
Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
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) => const saveGlobal = (config: Config.Info) =>
Effect.runPromise( Effect.runPromise(
Config.Service.use((svc) => svc.updateGlobal(config)).pipe( 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))) await Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer)))
if (wait) await InstanceRuntime.disposeAllInstances() if (wait) await InstanceRuntime.disposeAllInstances()
} }
const listDirs = () => const listDirs = (ctx: InstanceContext) =>
Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer))) Effect.runPromise(
const ready = () => Config.Service.use((svc) => provideCurrentInstance(svc.directories(), ctx)).pipe(
Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer))) 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) // Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
@@ -116,11 +138,11 @@ async function check(map: (dir: string) => string) {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: map(tmp.path), directory: map(tmp.path),
fn: async () => { fn: async (ctx) => {
const cfg = await load() const cfg = await load(ctx)
expect(cfg.snapshot).toBe(true) expect(cfg.snapshot).toBe(true)
expect(Instance.directory).toBe(Filesystem.resolve(tmp.path)) expect(ctx.directory).toBe(Filesystem.resolve(tmp.path))
expect(Instance.project.id).not.toBe(ProjectID.global) expect(ctx.project.id).not.toBe(ProjectID.global)
}, },
}) })
} finally { } finally {
@@ -134,8 +156,8 @@ test("loads config with defaults when no files exist", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.username).toBeDefined() expect(config.username).toBeDefined()
}, },
}) })
@@ -150,8 +172,8 @@ test("creates global jsonc config with schema when no global configs exist", asy
try { try {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
await load() await load(ctx)
}, },
}) })
@@ -175,8 +197,8 @@ test("does not create global config when OPENCODE_CONFIG_DIR is set", async () =
try { try {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
await load() await load(ctx)
}, },
}) })
@@ -201,8 +223,8 @@ test("loads JSON config file", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.model).toBe("test/model") expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser") expect(config.username).toBe("testuser")
}, },
@@ -220,8 +242,8 @@ test("loads shell config field", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.shell).toBe("bash") expect(config.shell).toBe("bash")
}, },
}) })
@@ -242,8 +264,8 @@ test("updates config and preserves empty shell sentinel", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
await save({ shell: "" }) await save({ shell: "" }, ctx)
const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "config.json")) const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "config.json"))
expect(writtenConfig.shell).toBe("") expect(writtenConfig.shell).toBe("")
@@ -320,8 +342,8 @@ test("loads formatter boolean config", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.formatter).toBe(true) expect(config.formatter).toBe(true)
}, },
}) })
@@ -338,8 +360,8 @@ test("loads lsp boolean config", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.lsp).toBe(true) expect(config.lsp).toBe(true)
}, },
}) })
@@ -375,8 +397,8 @@ test("ignores legacy tui keys in opencode config", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.model).toBe("test/model") expect(config.model).toBe("test/model")
expect((config as Record<string, unknown>).theme).toBeUndefined() expect((config as Record<string, unknown>).theme).toBeUndefined()
expect((config as Record<string, unknown>).tui).toBeUndefined() expect((config as Record<string, unknown>).tui).toBeUndefined()
@@ -400,8 +422,8 @@ test("loads JSONC config file", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.model).toBe("test/model") expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser") expect(config.username).toBe("testuser")
}, },
@@ -428,8 +450,8 @@ test("jsonc overrides json in the same directory", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.model).toBe("base") expect(config.model).toBe("base")
expect(config.username).toBe("base") expect(config.username).toBe("base")
}, },
@@ -451,8 +473,8 @@ test("handles environment variable substitution", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.username).toBe("test-user") expect(config.username).toBe("test-user")
}, },
}) })
@@ -483,8 +505,8 @@ test("preserves env variables when adding $schema to config", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.username).toBe("secret_value") expect(config.username).toBe("secret_value")
// Read the file to verify the env variable was preserved // Read the file to verify the env variable was preserved
@@ -580,8 +602,8 @@ test("handles file inclusion substitution", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.username).toBe("test-user") expect(config.username).toBe("test-user")
}, },
}) })
@@ -599,8 +621,8 @@ test("handles file inclusion with replacement tokens", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.username).toBe("const out = await Bun.$`echo hi`") 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({ await provideTestInstance({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
// Strict schema should throw an error for invalid fields // 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({ await provideTestInstance({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
await expect(load()).rejects.toThrow() await expect(load(ctx)).rejects.toThrow()
}, },
}) })
}) })
@@ -655,8 +677,8 @@ test("handles agent configuration", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.agent?.["test_agent"]).toEqual( expect(config.agent?.["test_agent"]).toEqual(
expect.objectContaining({ expect.objectContaining({
model: "test/model", model: "test/model",
@@ -686,8 +708,8 @@ test("treats agent variant as model-scoped setting (not provider option)", async
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
const agent = config.agent?.["test_agent"] const agent = config.agent?.["test_agent"]
expect(agent?.variant).toBe("xhigh") expect(agent?.variant).toBe("xhigh")
@@ -716,8 +738,8 @@ test("handles command configuration", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.command?.["test_command"]).toEqual({ expect(config.command?.["test_command"]).toEqual({
template: "test template", template: "test template",
description: "test command", description: "test command",
@@ -741,8 +763,8 @@ test("migrates autoshare to share field", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.share).toBe("auto") expect(config.share).toBe("auto")
expect(config.autoshare).toBe(true) expect(config.autoshare).toBe(true)
}, },
@@ -768,8 +790,8 @@ test("migrates mode field to agent field", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.agent?.["test_mode"]).toEqual({ expect(config.agent?.["test_mode"]).toEqual({
model: "test/model", model: "test/model",
temperature: 0.5, temperature: 0.5,
@@ -800,8 +822,8 @@ Test agent prompt`,
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.agent?.["test"]).toEqual( expect(config.agent?.["test"]).toEqual(
expect.objectContaining({ expect.objectContaining({
name: "test", name: "test",
@@ -833,8 +855,8 @@ Ordered permissions`,
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(Object.keys(config.agent?.ordered?.permission ?? {})).toEqual(["bash", "*", "edit"]) expect(Object.keys(config.agent?.ordered?.permission ?? {})).toEqual(["bash", "*", "edit"])
}, },
}) })
@@ -871,8 +893,8 @@ Nested agent prompt`,
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.agent?.["helper"]).toMatchObject({ expect(config.agent?.["helper"]).toMatchObject({
name: "helper", name: "helper",
@@ -920,8 +942,8 @@ Nested command template`,
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.command?.["hello"]).toEqual({ expect(config.command?.["hello"]).toEqual({
description: "Test command", description: "Test command",
@@ -965,8 +987,8 @@ Nested command template`,
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.command?.["hello"]).toEqual({ expect(config.command?.["hello"]).toEqual({
description: "Test command", description: "Test command",
@@ -985,9 +1007,9 @@ test("updates config and writes to file", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const newConfig = { model: "updated/model" } 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")) const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json"))
expect(writtenConfig.model).toBe("updated/model") expect(writtenConfig.model).toBe("updated/model")
@@ -999,8 +1021,8 @@ test("gets config directories", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const dirs = await listDirs() const dirs = await listDirs(ctx)
expect(dirs.length).toBeGreaterThanOrEqual(1) expect(dirs.length).toBeGreaterThanOrEqual(1)
}, },
}) })
@@ -1029,8 +1051,8 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as
try { try {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
await load() await load(ctx)
}, },
}) })
} finally { } finally {
@@ -1064,10 +1086,18 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
try { try {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
await Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)))
await Effect.runPromise( 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({ await provideTestInstance({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
const pluginEntries = config.plugin ?? [] const pluginEntries = config.plugin ?? []
expect(pluginEntries).toContain("@scope/plugin") expect(pluginEntries).toContain("@scope/plugin")
}, },
@@ -1161,8 +1191,8 @@ test("merges plugin arrays from global and local configs", async () => {
await provideTestInstance({ await provideTestInstance({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
const plugins = config.plugin ?? [] const plugins = config.plugin ?? []
// Should contain both global and local plugins // Should contain both global and local plugins
@@ -1197,8 +1227,8 @@ Helper subagent prompt`,
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.agent?.["helper"]).toMatchObject({ expect(config.agent?.["helper"]).toMatchObject({
name: "helper", name: "helper",
model: "test/model", model: "test/model",
@@ -1236,8 +1266,8 @@ test("merges instructions arrays from global and local configs", async () => {
await WithInstance.provide({ await WithInstance.provide({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
const instructions = config.instructions ?? [] const instructions = config.instructions ?? []
expect(instructions).toContain("global-instructions.md") expect(instructions).toContain("global-instructions.md")
@@ -1275,8 +1305,8 @@ test("deduplicates duplicate instructions from global and local configs", async
await WithInstance.provide({ await WithInstance.provide({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
const instructions = config.instructions ?? [] const instructions = config.instructions ?? []
expect(instructions).toContain("global-only.md") expect(instructions).toContain("global-only.md")
@@ -1320,8 +1350,8 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
await provideTestInstance({ await provideTestInstance({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
const plugins = config.plugin ?? [] const plugins = config.plugin ?? []
// Should contain all unique plugins // Should contain all unique plugins
@@ -1369,8 +1399,8 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
await provideTestInstance({ await provideTestInstance({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async (ctx) => {
const cfg = await load() const cfg = await load(ctx)
const plugins = cfg.plugin ?? [] const plugins = cfg.plugin ?? []
const origins = cfg.plugin_origins ?? [] const origins = cfg.plugin_origins ?? []
const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item)) const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item))
@@ -1410,8 +1440,8 @@ test("migrates legacy tools config to permissions - allow", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow", bash: "allow",
read: "allow", read: "allow",
@@ -1441,8 +1471,8 @@ test("migrates legacy tools config to permissions - deny", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
bash: "deny", bash: "deny",
webfetch: "deny", webfetch: "deny",
@@ -1471,8 +1501,8 @@ test("migrates legacy write tool to edit permission", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow", edit: "allow",
}) })
@@ -1503,8 +1533,8 @@ test("managed settings override user settings", async () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.model).toBe("managed/model") expect(config.model).toBe("managed/model")
expect(config.share).toBe("disabled") expect(config.share).toBe("disabled")
expect(config.username).toBe("testuser") expect(config.username).toBe("testuser")
@@ -1531,8 +1561,8 @@ test("managed settings override project settings", async () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.autoupdate).toBe(false) expect(config.autoupdate).toBe(false)
expect(config.disabled_providers).toEqual(["openai"]) expect(config.disabled_providers).toEqual(["openai"])
}, },
@@ -1551,8 +1581,8 @@ test("missing managed settings file is not an error", async () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.model).toBe("user/model") expect(config.model).toBe("user/model")
}, },
}) })
@@ -1578,8 +1608,8 @@ test("migrates legacy edit tool to edit permission", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
edit: "deny", edit: "deny",
}) })
@@ -1607,8 +1637,8 @@ test("migrates legacy patch tool to edit permission", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow", edit: "allow",
}) })
@@ -1639,8 +1669,8 @@ test("migrates mixed legacy tools config", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow", bash: "allow",
edit: "allow", edit: "allow",
@@ -1674,8 +1704,8 @@ test("merges legacy tools with existing permission config", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
glob: "allow", glob: "allow",
bash: "allow", bash: "allow",
@@ -1711,8 +1741,8 @@ test("permission config preserves user key order", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(Object.keys(config.permission!)).toEqual([ expect(Object.keys(config.permission!)).toEqual([
"*", "*",
"edit", "edit",
@@ -1794,8 +1824,8 @@ test("project config can override MCP server enabled status", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
// jira should be enabled (overridden by project config) // jira should be enabled (overridden by project config)
expect(config.mcp?.jira).toEqual({ expect(config.mcp?.jira).toEqual({
type: "remote", type: "remote",
@@ -1850,8 +1880,8 @@ test("MCP config deep merges preserving base config properties", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.mcp?.myserver).toEqual({ expect(config.mcp?.myserver).toEqual({
type: "remote", type: "remote",
url: "https://myserver.example.com/mcp", url: "https://myserver.example.com/mcp",
@@ -1901,8 +1931,8 @@ test("local .opencode config can override MCP from project config", async () =>
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.mcp?.docs?.enabled).toBe(true) expect(config.mcp?.docs?.enabled).toBe(true)
}, },
}) })
@@ -2235,8 +2265,8 @@ describe("deduplicatePluginOrigins", () => {
await provideTestInstance({ await provideTestInstance({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
const plugins = config.plugin ?? [] const plugins = config.plugin ?? []
expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true) 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
// Project config should NOT be loaded - model should be default, not "project/model" // Project config should NOT be loaded - model should be default, not "project/model"
expect(config.model).not.toBe("project/model") expect(config.model).not.toBe("project/model")
expect(config.username).not.toBe("project-user") expect(config.username).not.toBe("project-user")
@@ -2298,8 +2328,8 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const directories = await listDirs() const directories = await listDirs(ctx)
// Project .opencode should NOT be in directories list // Project .opencode should NOT be in directories list
const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path)) const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path))
expect(hasProjectOpencode).toBe(false) expect(hasProjectOpencode).toBe(false)
@@ -2322,9 +2352,9 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
// Should still get default config (from global or defaults) // Should still get default config (from global or defaults)
const config = await load() const config = await load(ctx)
expect(config).toBeDefined() expect(config).toBeDefined()
expect(config.username).toBeDefined() expect(config.username).toBeDefined()
}, },
@@ -2364,10 +2394,10 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
// The relative instruction should be skipped without error // The relative instruction should be skipped without error
// We're mainly verifying this doesn't throw and the config loads // We're mainly verifying this doesn't throw and the config loads
const config = await load() const config = await load(ctx)
expect(config).toBeDefined() expect(config).toBeDefined()
// The instruction should have been skipped (warning logged) // The instruction should have been skipped (warning logged)
// We can't easily test the warning was logged, but we verify // We can't easily test the warning was logged, but we verify
@@ -2424,8 +2454,8 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: projectTmp.path, directory: projectTmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
// Should load from OPENCODE_CONFIG_DIR, not project // Should load from OPENCODE_CONFIG_DIR, not project
expect(config.model).toBe("configdir/model") expect(config.model).toBe("configdir/model")
}, },
@@ -2459,8 +2489,8 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.username).toBe("test_api_key_12345") expect(config.username).toBe("test_api_key_12345")
}, },
}) })
@@ -2493,8 +2523,8 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const config = await load() const config = await load(ctx)
expect(config.username).toBe("secret_key_from_file") expect(config.username).toBe("secret_key_from_file")
}, },
}) })
@@ -139,7 +139,18 @@ async function initGitRepo(dir: string) {
await $`git commit -m "base"`.cwd(dir).quiet() 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) => const createWorkspace = (input: Workspace.CreateInput) =>
runWorkspace(Workspace.Service.use((workspace) => workspace.create(input))) runWorkspace(Workspace.Service.use((workspace) => workspace.create(input)))
const warpWorkspaceSession = (input: Workspace.SessionWarpInput) => const warpWorkspaceSession = (input: Workspace.SessionWarpInput) =>
@@ -917,7 +928,9 @@ describe("workspace CRUD", () => {
const previous = workspaceInfo(projectID, previousType) const previous = workspaceInfo(projectID, previousType)
insertWorkspace(previous) insertWorkspace(previous)
registerAdapter(projectID, previousType, localAdapter(workspaceTmp.path, { createDir: false }).adapter) 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) attachSessionToWorkspace(session.id, previous.id)
const workspaceCtx = await AppRuntime.runPromise( const workspaceCtx = await AppRuntime.runPromise(
@@ -3,7 +3,6 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { $ } from "bun" import { $ } from "bun"
import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
+8 -6
View File
@@ -11,9 +11,9 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import type { Config } from "@/config/config" import type { Config } from "@/config/config"
import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceRef } from "../../src/effect/instance-ref"
import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import type { InstanceContext } from "../../src/project/instance-context"
import { InstanceRuntime } from "../../src/project/instance-runtime" import { InstanceRuntime } from "../../src/project/instance-runtime"
import { InstanceStore } from "../../src/project/instance-store" import { InstanceStore } from "../../src/project/instance-store"
import { Instance } from "../../src/project/instance"
import { TestLLMServer } from "../lib/llm-server" import { TestLLMServer } from "../lib/llm-server"
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) 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>) => const runTestInstanceStore = <A>(fn: (store: InstanceStore.Interface) => Effect.Effect<A>) =>
testInstanceRuntime.runPromise(InstanceStore.Service.use(fn)) 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 })) const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory }))
try { try {
if (input.init) await testInstanceRuntime.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx))) 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 { } finally {
await runTestInstanceStore((store) => store.dispose(ctx)) await runTestInstanceStore((store) => store.dispose(ctx))
} }
@@ -157,9 +161,7 @@ export const provideInstance =
Effect.contextWith((services: Context.Context<R>) => Effect.contextWith((services: Context.Context<R>) =>
Effect.promise<A>(async () => { Effect.promise<A>(async () => {
const ctx = await runTestInstanceStore((store) => store.load({ directory })) const ctx = await runTestInstanceStore((store) => store.load({ directory }))
return Instance.restore(ctx, () => return Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx)))
Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))),
)
}), }),
) )
+24 -13
View File
@@ -4,7 +4,6 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { LSPClient } from "@/lsp/client" import { LSPClient } from "@/lsp/client"
import * as LSPServer from "@/lsp/server" import * as LSPServer from "@/lsp/server"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance" import { WithInstance } from "../../src/project/with-instance"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
@@ -28,12 +27,13 @@ describe("LSPClient interop", () => {
const client = await WithInstance.provide({ const client = await WithInstance.provide({
directory: process.cwd(), directory: process.cwd(),
fn: () => fn: (ctx) =>
LSPClient.create({ LSPClient.create({
serverID: "fake", serverID: "fake",
server: handle as unknown as LSPServer.Handle, server: handle as unknown as LSPServer.Handle,
root: process.cwd(), root: process.cwd(),
directory: process.cwd(), directory: process.cwd(),
instance: ctx,
}), }),
}) })
@@ -51,12 +51,13 @@ describe("LSPClient interop", () => {
const client = await WithInstance.provide({ const client = await WithInstance.provide({
directory: process.cwd(), directory: process.cwd(),
fn: () => fn: (ctx) =>
LSPClient.create({ LSPClient.create({
serverID: "fake", serverID: "fake",
server: handle as unknown as LSPServer.Handle, server: handle as unknown as LSPServer.Handle,
root: process.cwd(), root: process.cwd(),
directory: process.cwd(), directory: process.cwd(),
instance: ctx,
}), }),
}) })
@@ -74,12 +75,13 @@ describe("LSPClient interop", () => {
const client = await WithInstance.provide({ const client = await WithInstance.provide({
directory: process.cwd(), directory: process.cwd(),
fn: () => fn: (ctx) =>
LSPClient.create({ LSPClient.create({
serverID: "fake", serverID: "fake",
server: handle as unknown as LSPServer.Handle, server: handle as unknown as LSPServer.Handle,
root: process.cwd(), root: process.cwd(),
directory: process.cwd(), directory: process.cwd(),
instance: ctx,
}), }),
}) })
@@ -97,12 +99,13 @@ describe("LSPClient interop", () => {
const client = await WithInstance.provide({ const client = await WithInstance.provide({
directory: process.cwd(), directory: process.cwd(),
fn: () => fn: (ctx) =>
LSPClient.create({ LSPClient.create({
serverID: "fake", serverID: "fake",
server: handle as unknown as LSPServer.Handle, server: handle as unknown as LSPServer.Handle,
root: process.cwd(), root: process.cwd(),
directory: process.cwd(), directory: process.cwd(),
instance: ctx,
}), }),
}) })
@@ -124,7 +127,7 @@ describe("LSPClient interop", () => {
const client = await WithInstance.provide({ const client = await WithInstance.provide({
directory: process.cwd(), directory: process.cwd(),
fn: () => fn: (ctx) =>
LSPClient.create({ LSPClient.create({
serverID: "fake", serverID: "fake",
server: { server: {
@@ -133,6 +136,7 @@ describe("LSPClient interop", () => {
}, },
root: process.cwd(), root: process.cwd(),
directory: process.cwd(), directory: process.cwd(),
instance: ctx,
}), }),
}) })
@@ -153,12 +157,13 @@ describe("LSPClient interop", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const client = await LSPClient.create({ const client = await LSPClient.create({
serverID: "fake", serverID: "fake",
server: handle as unknown as LSPServer.Handle, server: handle as unknown as LSPServer.Handle,
root: tmp.path, root: tmp.path,
directory: tmp.path, directory: tmp.path,
instance: ctx,
}) })
await client.notify.open({ path: file }) await client.notify.open({ path: file })
@@ -196,12 +201,13 @@ describe("LSPClient interop", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const client = await LSPClient.create({ const client = await LSPClient.create({
serverID: "fake", serverID: "fake",
server: handle as unknown as LSPServer.Handle, server: handle as unknown as LSPServer.Handle,
root: tmp.path, root: tmp.path,
directory: tmp.path, directory: tmp.path,
instance: ctx,
}) })
const version = await client.notify.open({ path: file }) const version = await client.notify.open({ path: file })
@@ -242,12 +248,13 @@ describe("LSPClient interop", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const client = await LSPClient.create({ const client = await LSPClient.create({
serverID: "fake", serverID: "fake",
server: handle as unknown as LSPServer.Handle, server: handle as unknown as LSPServer.Handle,
root: tmp.path, root: tmp.path,
directory: tmp.path, directory: tmp.path,
instance: ctx,
}) })
const version = await client.notify.open({ path: file }) const version = await client.notify.open({ path: file })
@@ -289,12 +296,13 @@ describe("LSPClient interop", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const client = await LSPClient.create({ const client = await LSPClient.create({
serverID: "fake", serverID: "fake",
server: handle as unknown as LSPServer.Handle, server: handle as unknown as LSPServer.Handle,
root: tmp.path, root: tmp.path,
directory: tmp.path, directory: tmp.path,
instance: ctx,
}) })
await client.connection.sendRequest("test/configure-pull-diagnostics", { await client.connection.sendRequest("test/configure-pull-diagnostics", {
@@ -337,12 +345,13 @@ describe("LSPClient interop", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const client = await LSPClient.create({ const client = await LSPClient.create({
serverID: "fake", serverID: "fake",
server: handle as unknown as LSPServer.Handle, server: handle as unknown as LSPServer.Handle,
root: tmp.path, root: tmp.path,
directory: tmp.path, directory: tmp.path,
instance: ctx,
}) })
await client.connection.sendRequest("test/configure-pull-diagnostics", { await client.connection.sendRequest("test/configure-pull-diagnostics", {
@@ -390,12 +399,13 @@ describe("LSPClient interop", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const client = await LSPClient.create({ const client = await LSPClient.create({
serverID: "fake", serverID: "fake",
server: handle as unknown as LSPServer.Handle, server: handle as unknown as LSPServer.Handle,
root: tmp.path, root: tmp.path,
directory: tmp.path, directory: tmp.path,
instance: ctx,
}) })
await client.connection.sendRequest("test/configure-pull-diagnostics", { await client.connection.sendRequest("test/configure-pull-diagnostics", {
@@ -454,12 +464,13 @@ describe("LSPClient interop", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const client = await LSPClient.create({ const client = await LSPClient.create({
serverID: "fake", serverID: "fake",
server: handle as unknown as LSPServer.Handle, server: handle as unknown as LSPServer.Handle,
root: tmp.path, root: tmp.path,
directory: tmp.path, directory: tmp.path,
instance: ctx,
}) })
await client.connection.sendRequest("test/configure-pull-diagnostics", { await client.connection.sendRequest("test/configure-pull-diagnostics", {
@@ -15,10 +15,10 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Workspace } from "../../src/control-plane/workspace" import { Workspace } from "../../src/control-plane/workspace"
import { Plugin } from "../../src/plugin/index" import { Plugin } from "../../src/plugin/index"
import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store" import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project" import { Project } from "../../src/project/project"
import { Vcs } from "../../src/project/vcs" import { Vcs } from "../../src/project/vcs"
import { InstanceState } from "../../src/effect/instance-state"
import { Session } from "../../src/session/session" import { Session } from "../../src/session/session"
import { SessionPrompt } from "../../src/session/prompt" import { SessionPrompt } from "../../src/session/prompt"
import { SyncEvent } from "../../src/sync" import { SyncEvent } from "../../src/sync"
@@ -116,11 +116,12 @@ describe("plugin.workspace", () => {
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
yield* plugin.init() yield* plugin.init()
const workspace = yield* Workspace.Service const workspace = yield* Workspace.Service
const ctx = yield* InstanceState.context
const info = yield* workspace.create({ const info = yield* workspace.create({
type, type,
branch: null, branch: null,
extra: { key: "value" }, extra: { key: "value" },
projectID: Instance.project.id, projectID: ctx.project.id,
}) })
expect(info.type).toBe(type) expect(info.type).toBe(type)
@@ -4,9 +4,8 @@ import { Deferred, Effect, Fiber, Layer } from "effect"
import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceRef } from "../../src/effect/instance-ref"
import { registerDisposer } from "../../src/effect/instance-registry" import { registerDisposer } from "../../src/effect/instance-registry"
import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store" import { InstanceStore } from "../../src/project/instance-store"
import { TestInstance, tmpdirScoped } from "../fixture/fixture" import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
let bootstrapRun: Effect.Effect<void> = Effect.void let bootstrapRun: Effect.Effect<void> = Effect.void
@@ -37,7 +36,7 @@ const registerDisposerScoped = (disposer: (directory: string) => Promise<void>)
) )
describe("InstanceStore", () => { describe("InstanceStore", () => {
it.live("loads instance context without installing ALS for the caller", () => it.live("loads instance context", () =>
Effect.gen(function* () { Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true }) const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service const store = yield* InstanceStore.Service
@@ -45,7 +44,6 @@ describe("InstanceStore", () => {
expect(ctx.directory).toBe(dir) expect(ctx.directory).toBe(dir)
expect(ctx.worktree).toBe(dir) expect(ctx.worktree).toBe(dir)
expect(() => Instance.current).toThrow()
}), }),
) )
@@ -63,7 +61,6 @@ describe("InstanceStore", () => {
yield* store.load({ directory: dir }) yield* store.load({ directory: dir })
expect(initializedDirectory).toBe(dir) expect(initializedDirectory).toBe(dir)
expect(() => Instance.current).toThrow()
}), }),
) )
@@ -245,20 +242,4 @@ describe("InstanceStore", () => {
expect(disposed).toEqual([dir1, dir2]) 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 },
)
}) })
@@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { Git } from "../../src/git" 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 { InstanceRuntime } from "../../src/project/instance-runtime"
import { Worktree } from "../../src/worktree" import { Worktree } from "../../src/worktree"
import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
@@ -41,7 +41,10 @@ const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () {
const removeCreatedWorktree = (directory: string) => const removeCreatedWorktree = (directory: string) =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Worktree.Service 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)) yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx))
const ok = yield* svc.remove({ directory }) const ok = yield* svc.remove({ directory })
if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${directory}`)) if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${directory}`))
@@ -1,10 +1,10 @@
import { test, expect, describe } from "bun:test" import { afterEach, test, expect, describe } from "bun:test"
import path from "path" import path from "path"
import { unlink } from "fs/promises" import { unlink } from "fs/promises"
import { ProviderID } from "../../src/provider/schema" import { ProviderID } from "../../src/provider/schema"
import { tmpdir } from "../fixture/fixture" import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance" import type { InstanceContext } from "../../src/project/instance-context"
import { WithInstance } from "../../src/project/with-instance" import { WithInstance } from "../../src/project/with-instance"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { Env } from "../../src/env" import { Env } from "../../src/env"
@@ -12,17 +12,37 @@ import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Effect } from "effect" import { Effect } from "effect"
import { AppRuntime } from "../../src/effect/app-runtime" import { AppRuntime } from "../../src/effect/app-runtime"
import { InstanceRef } from "../../src/effect/instance-ref"
import { makeRuntime } from "../../src/effect/run-service" import { makeRuntime } from "../../src/effect/run-service"
const env = makeRuntime(Env.Service, Env.defaultLayer) 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( return AppRuntime.runPromise(
Effect.gen(function* () { Effect.gen(function* () {
const provider = yield* Provider.Service const provider = yield* Provider.Service
return yield* provider.list() 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("AWS_REGION", "us-east-1") set(ctx, "AWS_REGION", "us-east-1")
set("AWS_PROFILE", "default") set(ctx, "AWS_PROFILE", "default")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("AWS_REGION", "eu-west-1") set(ctx, "AWS_REGION", "eu-west-1")
set("AWS_PROFILE", "default") set(ctx, "AWS_PROFILE", "default")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("AWS_PROFILE", "") set(ctx, "AWS_PROFILE", "")
set("AWS_ACCESS_KEY_ID", "") set(ctx, "AWS_ACCESS_KEY_ID", "")
set("AWS_BEARER_TOKEN_BEDROCK", "") set(ctx, "AWS_BEARER_TOKEN_BEDROCK", "")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("AWS_PROFILE", "default") set(ctx, "AWS_PROFILE", "default")
set("AWS_ACCESS_KEY_ID", "test-key-id") set(ctx, "AWS_ACCESS_KEY_ID", "test-key-id")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("AWS_PROFILE", "default") set(ctx, "AWS_PROFILE", "default")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe( expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe(
"https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com", "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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") set(ctx, "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(ctx, "AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
set("AWS_PROFILE", "") set(ctx, "AWS_PROFILE", "")
set("AWS_ACCESS_KEY_ID", "") set(ctx, "AWS_ACCESS_KEY_ID", "")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("AWS_PROFILE", "default") set(ctx, "AWS_PROFILE", "default")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
// The model should exist with the us. prefix // The model should exist with the us. prefix
expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("AWS_PROFILE", "default") set(ctx, "AWS_PROFILE", "default")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("AWS_PROFILE", "default") set(ctx, "AWS_PROFILE", "default")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("AWS_PROFILE", "default") set(ctx, "AWS_PROFILE", "default")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
// Non-prefixed model should still be registered // Non-prefixed model should still be registered
expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
@@ -8,7 +8,6 @@ export {}
// import { ProviderID, ModelID } from "../../src/provider/schema" // import { ProviderID, ModelID } from "../../src/provider/schema"
// import { tmpdir } from "../fixture/fixture" // import { tmpdir } from "../fixture/fixture"
// import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance" import { WithInstance } from "../../src/project/with-instance"
// import { Provider } from "@/provider/provider" // import { Provider } from "@/provider/provider"
// import { Env } from "../../src/env" // import { Env } from "../../src/env"
+253 -229
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 { mkdir, unlink } from "fs/promises"
import path from "path" import path from "path"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { Global } from "@opencode-ai/core/global" 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 { WithInstance } from "../../src/project/with-instance"
import { Plugin } from "../../src/plugin/index" import { Plugin } from "../../src/plugin/index"
import { ModelsDev } from "@opencode-ai/core/models" import { ModelsDev } from "@opencode-ai/core/models"
@@ -14,6 +14,7 @@ import { Filesystem } from "@/util/filesystem"
import { Env } from "../../src/env" import { Env } from "../../src/env"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { AppRuntime } from "../../src/effect/app-runtime" import { AppRuntime } from "../../src/effect/app-runtime"
import { InstanceRef } from "../../src/effect/instance-ref"
import { makeRuntime } from "../../src/effect/run-service" import { makeRuntime } from "../../src/effect/run-service"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -22,8 +23,31 @@ import { Auth } from "@/auth"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
const env = makeRuntime(Env.Service, Env.defaultLayer) 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>()
const remove = (k: string) => env.runSync((svc) => svc.remove(k))
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> = {}) => const providerLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Provider.layer.pipe( Provider.layer.pipe(
@@ -36,41 +60,41 @@ const providerLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.provide(RuntimeFlags.layer(flags)), 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( return AppRuntime.runPromise(
Effect.gen(function* () { Effect.gen(function* () {
const provider = yield* Provider.Service const provider = yield* Provider.Service
return yield* fn(provider) return yield* fn(provider)
}), }).pipe(Effect.provideService(InstanceRef, ctx)),
) )
} }
async function list() { async function list(ctx: InstanceContext) {
return run((provider) => provider.list()) return run(ctx, (provider) => provider.list())
} }
async function getProvider(providerID: ProviderID) { async function getProvider(providerID: ProviderID, ctx: InstanceContext) {
return run((provider) => provider.getProvider(providerID)) return run(ctx, (provider) => provider.getProvider(providerID))
} }
async function getModel(providerID: ProviderID, modelID: ModelID) { async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) {
return run((provider) => provider.getModel(providerID, modelID)) return run(ctx, (provider) => provider.getModel(providerID, modelID))
} }
async function getLanguage(model: Provider.Model) { async function getLanguage(model: Provider.Model, ctx: InstanceContext) {
return run((provider) => provider.getLanguage(model)) return run(ctx, (provider) => provider.getLanguage(model))
} }
async function closest(providerID: ProviderID, query: string[]) { async function closest(providerID: ProviderID, query: string[], ctx: InstanceContext) {
return run((provider) => provider.closest(providerID, query)) return run(ctx, (provider) => provider.closest(providerID, query))
} }
async function getSmallModel(providerID: ProviderID) { async function getSmallModel(providerID: ProviderID, ctx: InstanceContext) {
return run((provider) => provider.getSmallModel(providerID)) return run(ctx, (provider) => provider.getSmallModel(providerID))
} }
async function defaultModel() { async function defaultModel(ctx: InstanceContext) {
return run((provider) => provider.defaultModel()) return run(ctx, (provider) => provider.defaultModel())
} }
async function markPluginDependenciesReady(dir: string) { async function markPluginDependenciesReady(dir: string) {
@@ -125,9 +149,9 @@ test("provider loaded from env variable", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
// Provider should retain its connection source even if custom loaders // Provider should retain its connection source even if custom loaders
// merge additional options. // merge additional options.
@@ -157,8 +181,8 @@ test("provider loaded from config with apiKey option", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
}, },
}) })
@@ -178,9 +202,9 @@ test("disabled_providers excludes provider", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeUndefined() expect(providers[ProviderID.anthropic]).toBeUndefined()
}, },
}) })
@@ -200,10 +224,10 @@ test("enabled_providers restricts to only listed providers", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
set("OPENAI_API_KEY", "test-openai-key") set(ctx, "OPENAI_API_KEY", "test-openai-key")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeUndefined() expect(providers[ProviderID.openai]).toBeUndefined()
}, },
@@ -228,9 +252,9 @@ test("model whitelist filters models for provider", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models) const models = Object.keys(providers[ProviderID.anthropic].models)
expect(models).toContain("claude-sonnet-4-20250514") expect(models).toContain("claude-sonnet-4-20250514")
@@ -257,9 +281,9 @@ test("model blacklist excludes specific models", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models) const models = Object.keys(providers[ProviderID.anthropic].models)
expect(models).not.toContain("claude-sonnet-4-20250514") expect(models).not.toContain("claude-sonnet-4-20250514")
@@ -290,9 +314,9 @@ test("custom model alias via config", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined() expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias") 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.make("custom-provider")]).toBeDefined() expect(providers[ProviderID.make("custom-provider")]).toBeDefined()
expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider") expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider")
expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined() 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
const provider = providers[ProviderID.make("custom-provider")] const provider = providers[ProviderID.make("custom-provider")]
expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" }) expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" }) 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "env-api-key") set(ctx, "ANTHROPIC_API_KEY", "env-api-key")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
// Config options should be merged // Config options should be merged
expect(providers[ProviderID.anthropic].options.timeout).toBe(60000) expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
@@ -469,13 +493,13 @@ test("getModel returns model for valid provider/model", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx)
expect(model).toBeDefined() expect(model).toBeDefined()
expect(String(model.providerID)).toBe("anthropic") expect(String(model.providerID)).toBe("anthropic")
expect(String(model.id)).toBe("claude-sonnet-4-20250514") expect(String(model.id)).toBe("claude-sonnet-4-20250514")
const language = await getLanguage(model) const language = await getLanguage(model, ctx)
expect(language).toBeDefined() expect(language).toBeDefined()
}, },
}) })
@@ -494,9 +518,9 @@ test("getModel throws ModelNotFoundError for invalid model", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow() 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await defaultModel() const model = await defaultModel(ctx)
expect(model.providerID).toBeDefined() expect(model.providerID).toBeDefined()
expect(model.modelID).toBeDefined() expect(model.modelID).toBeDefined()
}, },
@@ -568,9 +592,9 @@ test("defaultModel respects config model setting", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await defaultModel() const model = await defaultModel(ctx)
expect(String(model.providerID)).toBe("anthropic") expect(String(model.providerID)).toBe("anthropic")
expect(String(model.modelID)).toBe("claude-sonnet-4-20250514") expect(String(model.modelID)).toBe("claude-sonnet-4-20250514")
}, },
@@ -701,9 +725,9 @@ test("closest finds model by partial match", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const result = await closest(ProviderID.anthropic, ["sonnet-4"]) const result = await closest(ProviderID.anthropic, ["sonnet-4"], ctx)
expect(result).toBeDefined() expect(result).toBeDefined()
expect(String(result?.providerID)).toBe("anthropic") expect(String(result?.providerID)).toBe("anthropic")
expect(String(result?.modelID)).toContain("sonnet-4") expect(String(result?.modelID)).toContain("sonnet-4")
@@ -724,8 +748,8 @@ test("closest returns undefined for nonexistent provider", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const result = await closest(ProviderID.make("nonexistent"), ["model"]) const result = await closest(ProviderID.make("nonexistent"), ["model"], ctx)
expect(result).toBeUndefined() expect(result).toBeUndefined()
}, },
}) })
@@ -754,12 +778,12 @@ test("getModel uses realIdByKey for aliased models", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined() 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(model).toBeDefined()
expect(String(model.id)).toBe("my-sonnet") expect(String(model.id)).toBe("my-sonnet")
expect(model.name).toBe("My Sonnet Alias") expect(model.name).toBe("My Sonnet Alias")
@@ -798,8 +822,8 @@ test("provider api field sets model api.url", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
// api field is stored on model.api.url, used by getSDK to set baseURL // 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") 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1") 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.name).toBe("Custom Name for Sonnet") expect(model.name).toBe("Custom Name for Sonnet")
expect(model.capabilities.toolcall).toBe(true) expect(model.capabilities.toolcall).toBe(true)
@@ -893,9 +917,9 @@ test("disabled_providers prevents loading even with env var", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("OPENAI_API_KEY", "test-openai-key") set(ctx, "OPENAI_API_KEY", "test-openai-key")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.openai]).toBeUndefined() expect(providers[ProviderID.openai]).toBeUndefined()
}, },
}) })
@@ -915,10 +939,10 @@ test("enabled_providers with empty array allows no providers", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
set("OPENAI_API_KEY", "test-openai-key") set(ctx, "OPENAI_API_KEY", "test-openai-key")
const providers = await list() const providers = await list(ctx)
expect(Object.keys(providers).length).toBe(0) expect(Object.keys(providers).length).toBe(0)
}, },
}) })
@@ -943,9 +967,9 @@ test("whitelist and blacklist can be combined", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models) const models = Object.keys(providers[ProviderID.anthropic].models)
expect(models).toContain("claude-sonnet-4-20250514") expect(models).toContain("claude-sonnet-4-20250514")
@@ -983,8 +1007,8 @@ test("model modalities default correctly", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
const model = providers[ProviderID.make("test-provider")].models["test-model"] const model = providers[ProviderID.make("test-provider")].models["test-model"]
expect(model.capabilities.input.text).toBe(true) expect(model.capabilities.input.text).toBe(true)
expect(model.capabilities.output.text).toBe(true) expect(model.capabilities.output.text).toBe(true)
@@ -1026,8 +1050,8 @@ test("model with custom cost values", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
const model = providers[ProviderID.make("test-provider")].models["test-model"] const model = providers[ProviderID.make("test-provider")].models["test-model"]
expect(model.cost.input).toBe(5) expect(model.cost.input).toBe(5)
expect(model.cost.output).toBe(15) expect(model.cost.output).toBe(15)
@@ -1050,9 +1074,9 @@ test("getSmallModel returns appropriate small model", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await getSmallModel(ProviderID.anthropic) const model = await getSmallModel(ProviderID.anthropic, ctx)
expect(model).toBeDefined() expect(model).toBeDefined()
expect(model?.id).toContain("haiku") expect(model?.id).toContain("haiku")
}, },
@@ -1073,9 +1097,9 @@ test("getSmallModel respects config small_model override", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await getSmallModel(ProviderID.anthropic) const model = await getSmallModel(ProviderID.anthropic, ctx)
expect(model).toBeDefined() expect(model).toBeDefined()
expect(String(model?.providerID)).toBe("anthropic") expect(String(model?.providerID)).toBe("anthropic")
expect(String(model?.id)).toBe("claude-sonnet-4-20250514") expect(String(model?.id)).toBe("claude-sonnet-4-20250514")
@@ -1097,9 +1121,9 @@ test("getSmallModel ignores invalid config small_model", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
expect(await getSmallModel(ProviderID.anthropic)).toBeUndefined() expect(await getSmallModel(ProviderID.anthropic, ctx)).toBeUndefined()
}, },
}) })
}) })
@@ -1140,10 +1164,10 @@ test("multiple providers can be configured simultaneously", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-anthropic-key") set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key")
set("OPENAI_API_KEY", "test-openai-key") set(ctx, "OPENAI_API_KEY", "test-openai-key")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeDefined() expect(providers[ProviderID.openai]).toBeDefined()
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
@@ -1183,8 +1207,8 @@ test("provider with custom npm package", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.make("local-llm")]).toBeDefined() 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")].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible")
expect(providers[ProviderID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1") 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet") 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("MULTI_ENV_KEY_1", "test-key") set(ctx, "MULTI_ENV_KEY_1", "test-key")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.make("multi-env")]).toBeDefined() expect(providers[ProviderID.make("multi-env")]).toBeDefined()
// When multiple env options exist, key should NOT be auto-set // When multiple env options exist, key should NOT be auto-set
expect(providers[ProviderID.make("multi-env")].key).toBeUndefined() 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("SINGLE_ENV_KEY", "my-api-key") set(ctx, "SINGLE_ENV_KEY", "my-api-key")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.make("single-env")]).toBeDefined() expect(providers[ProviderID.make("single-env")]).toBeDefined()
// Single env option should auto-set key // Single env option should auto-set key
expect(providers[ProviderID.make("single-env")].key).toBe("my-api-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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.cost.input).toBe(999) expect(model.cost.input).toBe(999)
expect(model.cost.output).toBe(888) expect(model.cost.output).toBe(888)
@@ -1378,8 +1402,8 @@ test("completely new provider not in database can be configured", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.make("brand-new-provider")]).toBeDefined() expect(providers[ProviderID.make("brand-new-provider")]).toBeDefined()
expect(providers[ProviderID.make("brand-new-provider")].name).toBe("Brand New") expect(providers[ProviderID.make("brand-new-provider")].name).toBe("Brand New")
const model = providers[ProviderID.make("brand-new-provider")].models["new-model"] 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-anthropic") set(ctx, "ANTHROPIC_API_KEY", "test-anthropic")
set("OPENAI_API_KEY", "test-openai") set(ctx, "OPENAI_API_KEY", "test-openai")
set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") set(ctx, "GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
const providers = await list() const providers = await list(ctx)
// anthropic: in enabled, not in disabled = allowed // anthropic: in enabled, not in disabled = allowed
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
// openai: in enabled, but also in disabled = NOT allowed // openai: in enabled, but also in disabled = NOT allowed
@@ -1450,8 +1474,8 @@ test("model with tool_call false", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false) 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true) 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
const model = providers[ProviderID.make("headers-provider")].models["model"] const model = providers[ProviderID.make("headers-provider")].models["model"]
expect(model.headers).toEqual({ expect(model.headers).toEqual({
"X-Custom-Header": "custom-value", "X-Custom-Header": "custom-value",
@@ -1563,10 +1587,10 @@ test("provider env fallback - second env var used if first missing", async () =>
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
// Only set fallback, not primary // Only set fallback, not primary
set("FALLBACK_KEY", "fallback-api-key") set(ctx, "FALLBACK_KEY", "fallback-api-key")
const providers = await list() const providers = await list(ctx)
// Provider should load because fallback env var is set // Provider should load because fallback env var is set
expect(providers[ProviderID.make("fallback-env")]).toBeDefined() expect(providers[ProviderID.make("fallback-env")]).toBeDefined()
}, },
@@ -1586,10 +1610,10 @@ test("getModel returns consistent results", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) 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")) const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx)
expect(model1.providerID).toEqual(model2.providerID) expect(model1.providerID).toEqual(model2.providerID)
expect(model1.id).toEqual(model2.id) expect(model1.id).toEqual(model2.id)
expect(model1).toEqual(model2) expect(model1).toEqual(model2)
@@ -1625,8 +1649,8 @@ test("provider name defaults to id when not in database", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.make("my-custom-id")].name).toBe("my-custom-id") 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
try { 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 expect(true).toBe(false) // Should not reach here
} catch (e: any) { } catch (e: any) {
expect(e.suggestions).toBeDefined() expect(e.suggestions).toBeDefined()
@@ -1671,10 +1695,10 @@ test("ModelNotFoundError for provider includes suggestions", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
try { 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 expect(true).toBe(false) // Should not reach here
} catch (e: any) { } catch (e: any) {
expect(e.suggestions).toBeDefined() expect(e.suggestions).toBeDefined()
@@ -1697,10 +1721,10 @@ test("ModelNotFoundError suggests catalog models for unloaded providers", async
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
remove("OPENCODE_API_KEY") remove(ctx, "OPENCODE_API_KEY")
try { 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") throw new Error("expected model lookup to fail")
} catch (e) { } catch (e) {
if (!Provider.ModelNotFoundError.isInstance(e)) throw e if (!Provider.ModelNotFoundError.isInstance(e)) throw e
@@ -1723,8 +1747,8 @@ test("getProvider returns undefined for nonexistent provider", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const provider = await getProvider(ProviderID.make("nonexistent")) const provider = await getProvider(ProviderID.make("nonexistent"), ctx)
expect(provider).toBeUndefined() expect(provider).toBeUndefined()
}, },
}) })
@@ -1743,9 +1767,9 @@ test("getProvider returns provider info", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const provider = await getProvider(ProviderID.anthropic) const provider = await getProvider(ProviderID.anthropic, ctx)
expect(provider).toBeDefined() expect(provider).toBeDefined()
expect(String(provider?.id)).toBe("anthropic") expect(String(provider?.id)).toBe("anthropic")
}, },
@@ -1765,9 +1789,9 @@ test("closest returns undefined when no partial match found", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"], ctx)
expect(result).toBeUndefined() expect(result).toBeUndefined()
}, },
}) })
@@ -1786,10 +1810,10 @@ test("closest checks multiple query terms in order", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
// First term won't match, second will // 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).toBeDefined()
expect(result?.modelID).toContain("haiku") expect(result?.modelID).toContain("haiku")
}, },
@@ -1824,8 +1848,8 @@ test("model limit defaults to zero when not specified", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
const model = providers[ProviderID.make("no-limit")].models["model"] const model = providers[ProviderID.make("no-limit")].models["model"]
expect(model.limit.context).toBe(0) expect(model.limit.context).toBe(0)
expect(model.limit.output).toBe(0) expect(model.limit.output).toBe(0)
@@ -1856,9 +1880,9 @@ test("provider options are deeply merged", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
// Custom options should be merged // Custom options should be merged
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
expect(providers[ProviderID.anthropic].options.headers["X-Custom"]).toBe("custom-value") 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.make("nvidia")].options.headers).toEqual({ expect(providers[ProviderID.make("nvidia")].options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
@@ -1920,8 +1944,8 @@ test("custom nvidia baseURL adds billing origin header", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.make("nvidia")].options.headers).toEqual({ expect(providers[ProviderID.make("nvidia")].options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
@@ -1955,8 +1979,8 @@ test("explicit nvidia billing origin header is preserved", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.make("nvidia")].options.headers["X-BILLING-INVOKE-ORIGIN"]).toBe("CustomOrigin") 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("OPENAI_API_KEY", "test-api-key") set(ctx, "OPENAI_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
const model = providers[ProviderID.openai].models["my-custom-model"] const model = providers[ProviderID.openai].models["my-custom-model"]
expect(model).toBeDefined() expect(model).toBeDefined()
expect(model.api.npm).toBe("@ai-sdk/openai") 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("OPENROUTER_API_KEY", "test-api-key") set(ctx, "OPENROUTER_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.openrouter]).toBeDefined() expect(providers[ProviderID.openrouter]).toBeDefined()
// New model not in database should inherit api.url from provider // 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
// Claude sonnet 4 has reasoning capability // Claude sonnet 4 has reasoning capability
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.capabilities.reasoning).toBe(true) expect(model.capabilities.reasoning).toBe(true)
@@ -2186,9 +2210,9 @@ test("model variants can be disabled via config", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined() expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined() expect(model.variants!["high"]).toBeUndefined()
@@ -2227,9 +2251,9 @@ test("model variants can be customized via config", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined() expect(model.variants!["high"]).toBeDefined()
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000) expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
@@ -2264,9 +2288,9 @@ test("disabled key is stripped from variant config", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["max"]).toBeDefined() expect(model.variants!["max"]).toBeDefined()
expect(model.variants!["max"].disabled).toBeUndefined() expect(model.variants!["max"].disabled).toBeUndefined()
@@ -2300,9 +2324,9 @@ test("all variants can be disabled via config", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined() expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBe(0) expect(Object.keys(model.variants!).length).toBe(0)
@@ -2336,9 +2360,9 @@ test("variant config merges with generated variants", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-api-key") set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined() expect(model.variants!["high"]).toBeDefined()
// Should have both the generated thinking config and the custom option // 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("OPENAI_API_KEY", "test-api-key") set(ctx, "OPENAI_API_KEY", "test-api-key")
const providers = await list() const providers = await list(ctx)
const model = providers[ProviderID.openai].models["gpt-5"] const model = providers[ProviderID.openai].models["gpt-5"]
expect(model.variants).toBeDefined() expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined() expect(model.variants!["high"]).toBeUndefined()
@@ -2419,8 +2443,8 @@ test("custom model with variants enabled and disabled", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const providers = await list() const providers = await list(ctx)
const model = providers[ProviderID.make("custom-reasoning")].models["reasoning-model"] const model = providers[ProviderID.make("custom-reasoning")].models["reasoning-model"]
expect(model.variants).toBeDefined() expect(model.variants).toBeDefined()
// Enabled variants should exist // Enabled variants should exist
@@ -2474,9 +2498,9 @@ test("Google Vertex: retains baseURL for custom proxy", async () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined() expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined()
expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1") 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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
const providers = await list() const providers = await list(ctx)
const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"] const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"]
expect(model).toBeDefined() expect(model).toBeDefined()
@@ -2541,11 +2565,11 @@ test("cloudflare-ai-gateway loads with env variables", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("CLOUDFLARE_ACCOUNT_ID", "test-account") set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account")
set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set(ctx, "CLOUDFLARE_GATEWAY_ID", "test-gateway")
set("CLOUDFLARE_API_TOKEN", "test-token") set(ctx, "CLOUDFLARE_API_TOKEN", "test-token")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
}, },
}) })
@@ -2571,11 +2595,11 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => {
}) })
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("CLOUDFLARE_ACCOUNT_ID", "test-account") set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account")
set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set(ctx, "CLOUDFLARE_GATEWAY_ID", "test-gateway")
set("CLOUDFLARE_API_TOKEN", "test-token") set(ctx, "CLOUDFLARE_API_TOKEN", "test-token")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({ expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({
invoked_by: "test", invoked_by: "test",
@@ -2624,14 +2648,14 @@ test("plugin config providers persist after instance dispose", async () => {
const first = await WithInstance.provide({ const first = await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => fn: async (ctx) =>
AppRuntime.runPromise( AppRuntime.runPromise(
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const provider = yield* Provider.Service const provider = yield* Provider.Service
yield* plugin.init() yield* plugin.init()
return yield* provider.list() return yield* provider.list()
}), }).pipe(Effect.provideService(InstanceRef, ctx)),
), ),
}) })
expect(first[ProviderID.make("demo")]).toBeDefined() expect(first[ProviderID.make("demo")]).toBeDefined()
@@ -2641,7 +2665,7 @@ test("plugin config providers persist after instance dispose", async () => {
const second = await WithInstance.provide({ const second = await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => list(), fn: async (ctx) => list(ctx),
}) })
expect(second[ProviderID.make("demo")]).toBeDefined() expect(second[ProviderID.make("demo")]).toBeDefined()
expect(second[ProviderID.make("demo")].models[ModelID.make("chat")]).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({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
set("ANTHROPIC_API_KEY", "test-anthropic-key") set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key")
set("OPENAI_API_KEY", "test-openai-key") set(ctx, "OPENAI_API_KEY", "test-openai-key")
const providers = await list() const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeUndefined() 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({ const none = await WithInstance.provide({
directory: base.path, directory: base.path,
fn: async () => paid(await list()), fn: async (ctx) => paid(await list(ctx)),
}) })
await using keyed = await tmpdir({ 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({ const keyedCount = await WithInstance.provide({
directory: keyed.path, directory: keyed.path,
fn: async () => paid(await list()), fn: async (ctx) => paid(await list(ctx)),
}) })
expect(none).toBe(0) expect(none).toBe(0)
@@ -2740,7 +2764,7 @@ test("opencode loader keeps paid models when auth exists", async () => {
const none = await WithInstance.provide({ const none = await WithInstance.provide({
directory: base.path, directory: base.path,
fn: async () => paid(await list()), fn: async (ctx) => paid(await list(ctx)),
}) })
await using keyed = await tmpdir({ await using keyed = await tmpdir({
@@ -2774,7 +2798,7 @@ test("opencode loader keeps paid models when auth exists", async () => {
const keyedCount = await WithInstance.provide({ const keyedCount = await WithInstance.provide({
directory: keyed.path, directory: keyed.path,
fn: async () => paid(await list()), fn: async (ctx) => paid(await list(ctx)),
}) })
expect(none).toBe(0) expect(none).toBe(0)
@@ -1,7 +1,7 @@
import { afterEach, expect } from "bun:test" import { afterEach, expect } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer, Queue } from "effect" import { Cause, Effect, Exit, Fiber, Layer, Queue } from "effect"
import { Question } from "../../src/question" 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 { InstanceRuntime } from "../../src/project/instance-runtime"
import { QuestionID } from "../../src/question/schema" import { QuestionID } from "../../src/question/schema"
import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" 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) }).pipe(provideInstance(dir), Effect.forkScoped)
expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) 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)) yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx))
const exit = yield* Fiber.await(fiber) const exit = yield* Fiber.await(fiber)
@@ -1,11 +1,12 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { Bus } from "../../src/bus" 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 { Server } from "../../src/server/server"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { Event as ServerEvent } from "../../src/server/event" import { Event as ServerEvent } from "../../src/server/event"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { Schema } from "effect" import { Effect, Schema } from "effect"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, reloadTestInstance, tmpdir } from "../fixture/fixture" import { disposeAllInstances, reloadTestInstance, tmpdir } from "../fixture/fixture"
@@ -108,7 +109,9 @@ describe("event HttpApi", () => {
const next = readEvent(reader) const next = readEvent(reader)
const ctx = await reloadTestInstance({ directory: tmp.path }) 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: {} }) expect(await next).toMatchObject({ type: "server.connected", properties: {} })
} finally { } finally {
@@ -3,7 +3,6 @@ export type Runtime = {
HttpApiApp: (typeof import("../../../src/server/routes/instance/httpapi/server"))["HttpApiApp"] HttpApiApp: (typeof import("../../../src/server/routes/instance/httpapi/server"))["HttpApiApp"]
AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"] AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"]
InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"] InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"]
Instance: (typeof import("../../../src/project/instance"))["Instance"]
InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"] InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"]
Session: (typeof import("../../../src/session/session"))["Session"] Session: (typeof import("../../../src/session/session"))["Session"]
Todo: (typeof import("../../../src/session/todo"))["Todo"] 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 httpApiServer = await import("../../../src/server/routes/instance/httpapi/server")
const appRuntime = await import("../../../src/effect/app-runtime") const appRuntime = await import("../../../src/effect/app-runtime")
const instanceRef = await import("../../../src/effect/instance-ref") const instanceRef = await import("../../../src/effect/instance-ref")
const instance = await import("../../../src/project/instance")
const instanceStore = await import("../../../src/project/instance-store") const instanceStore = await import("../../../src/project/instance-store")
const session = await import("../../../src/session/session") const session = await import("../../../src/session/session")
const todo = await import("../../../src/session/todo") const todo = await import("../../../src/session/todo")
@@ -37,7 +35,6 @@ export function runtime() {
HttpApiApp: httpApiServer.HttpApiApp, HttpApiApp: httpApiServer.HttpApiApp,
AppLayer: appRuntime.AppLayer, AppLayer: appRuntime.AppLayer,
InstanceRef: instanceRef.InstanceRef, InstanceRef: instanceRef.InstanceRef,
Instance: instance.Instance,
InstanceStore: instanceStore.InstanceStore, InstanceStore: instanceStore.InstanceStore,
Session: session.Session, Session: session.Session,
Todo: todo.Todo, Todo: todo.Todo,
@@ -3,7 +3,6 @@ import { Context } from "effect"
import path from "path" import path from "path"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" 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 * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, tmpdir } from "../fixture/fixture"
@@ -10,7 +10,6 @@ import { WorkspaceID } from "../../src/control-plane/schema"
import type { WorkspaceAdapter } from "../../src/control-plane/types" import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace" import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"
import { InstanceLayer } from "../../src/project/instance-layer" import { InstanceLayer } from "../../src/project/instance-layer"
import { Project } from "../../src/project/project" import { Project } from "../../src/project/project"
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
@@ -1,7 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { PtyID } from "../../src/pty/schema" import { PtyID } from "../../src/pty/schema"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
@@ -1,7 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { ConfigProvider, Layer } from "effect" import { ConfigProvider, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http" import { HttpRouter } from "effect/unstable/http"
import { Instance } from "../../src/project/instance"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
@@ -13,7 +13,6 @@ import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceStore } from "../../src/project/instance-store" import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project" import { Project } from "../../src/project/project"
@@ -71,7 +70,7 @@ function listedAdapter(directory: string, type: string): WorkspaceAdapter {
}, },
async create() {}, async create() {},
async remove() {}, async remove() {},
list() { list(context) {
return [ return [
{ {
type, type,
@@ -79,7 +78,7 @@ function listedAdapter(directory: string, type: string): WorkspaceAdapter {
branch: "listed/main", branch: "listed/main",
directory, directory,
extra: { listed: true }, 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 { function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter {
return { return {
name: "Remote Test", name: "Remote Test",
+146 -122
View File
@@ -4,8 +4,9 @@ import { tool, type ModelMessage } from "ai"
import { Cause, Effect, Exit, Stream } from "effect" import { Cause, Effect, Exit, Stream } from "effect"
import z from "zod" import z from "zod"
import { makeRuntime } from "../../src/effect/run-service" import { makeRuntime } from "../../src/effect/run-service"
import { InstanceRef } from "../../src/effect/instance-ref"
import { LLM } from "../../src/session/llm" 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 { WithInstance } from "../../src/project/with-instance"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform" import { ProviderTransform } from "@/provider/transform"
@@ -18,19 +19,21 @@ import { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime" import { AppRuntime } from "../../src/effect/app-runtime"
async function getModel(providerID: ProviderID, modelID: ModelID) { async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) {
return AppRuntime.runPromise( const effect = Effect.gen(function* () {
Effect.gen(function* () { const provider = yield* Provider.Service
const provider = yield* Provider.Service return yield* provider.getModel(providerID, modelID)
return yield* provider.getModel(providerID, modelID) })
}), return AppRuntime.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
)
} }
const llm = makeRuntime(LLM.Service, LLM.defaultLayer) const llm = makeRuntime(LLM.Service, LLM.defaultLayer)
async function drain(input: LLM.StreamInput) { async function drain(input: LLM.StreamInput, ctx: InstanceContext) {
return llm.runPromise((svc) => svc.stream(input).pipe(Stream.runDrain)) return llm.runPromise((svc) => {
const effect = svc.stream(input).pipe(Stream.runDrain)
return effect.pipe(Effect.provideService(InstanceRef, ctx))
})
} }
describe("session.llm.hasToolCalls", () => { describe("session.llm.hasToolCalls", () => {
@@ -360,8 +363,8 @@ describe("session.llm.stream", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-1") const sessionID = SessionID.make("session-test-1")
const agent = { const agent = {
name: "test", name: "test",
@@ -381,15 +384,18 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make(providerID), modelID: resolved.id, variant: "high" }, model: { providerID: ProviderID.make(providerID), modelID: resolved.id, variant: "high" },
} satisfies MessageV2.User } satisfies MessageV2.User
await drain({ await drain(
user, {
sessionID, user,
model: resolved, sessionID,
agent, model: resolved,
system: ["You are a helpful assistant."], agent,
messages: [{ role: "user", content: "Hello" }], system: ["You are a helpful assistant."],
tools: {}, messages: [{ role: "user", content: "Hello" }],
}) tools: {},
},
ctx,
)
const capture = await request const capture = await request
const body = capture.body const body = capture.body
@@ -447,8 +453,8 @@ describe("session.llm.stream", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-service-abort") const sessionID = SessionID.make("session-test-service-abort")
const agent = { const agent = {
name: "test", name: "test",
@@ -478,7 +484,7 @@ describe("session.llm.stream", () => {
messages: [{ role: "user", content: "Hello" }], messages: [{ role: "user", content: "Hello" }],
tools: {}, tools: {},
}) })
.pipe(Stream.runDrain), .pipe(Stream.runDrain, Effect.provideService(InstanceRef, ctx)),
{ signal: ctrl.signal }, { signal: ctrl.signal },
) )
@@ -537,8 +543,8 @@ describe("session.llm.stream", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-tools") const sessionID = SessionID.make("session-test-tools")
const agent = { const agent = {
name: "test", name: "test",
@@ -557,22 +563,25 @@ describe("session.llm.stream", () => {
tools: { question: true }, tools: { question: true },
} satisfies MessageV2.User } satisfies MessageV2.User
await drain({ await drain(
user, {
sessionID, user,
model: resolved, sessionID,
agent, model: resolved,
permission: [{ permission: "question", pattern: "*", action: "allow" }], agent,
system: ["You are a helpful assistant."], permission: [{ permission: "question", pattern: "*", action: "allow" }],
messages: [{ role: "user", content: "Hello" }], system: ["You are a helpful assistant."],
tools: { messages: [{ role: "user", content: "Hello" }],
question: tool({ tools: {
description: "Ask a question", question: tool({
inputSchema: z.object({}), description: "Ask a question",
execute: async () => ({ output: "" }), inputSchema: z.object({}),
}), execute: async () => ({ output: "" }),
}),
},
}, },
}) ctx,
)
const capture = await request const capture = await request
const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined
@@ -651,8 +660,8 @@ describe("session.llm.stream", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-2") const sessionID = SessionID.make("session-test-2")
const agent = { const agent = {
name: "test", name: "test",
@@ -671,15 +680,18 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" }, model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
} satisfies MessageV2.User } satisfies MessageV2.User
await drain({ await drain(
user, {
sessionID, user,
model: resolved, sessionID,
agent, model: resolved,
system: ["You are a helpful assistant."], agent,
messages: [{ role: "user", content: "Hello" }], system: ["You are a helpful assistant."],
tools: {}, messages: [{ role: "user", content: "Hello" }],
}) tools: {},
},
ctx,
)
const capture = await request const capture = await request
const body = capture.body const body = capture.body
@@ -767,8 +779,8 @@ describe("session.llm.stream", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-data-url") const sessionID = SessionID.make("session-test-data-url")
const agent = { const agent = {
name: "test", name: "test",
@@ -786,28 +798,31 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make("openai"), modelID: resolved.id }, model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
} satisfies MessageV2.User } satisfies MessageV2.User
await drain({ await drain(
user, {
sessionID, user,
model: resolved, sessionID,
agent, model: resolved,
system: ["You are a helpful assistant."], agent,
messages: [ system: ["You are a helpful assistant."],
{ messages: [
role: "user", {
content: [ role: "user",
{ type: "text", text: "Describe this image" }, content: [
{ { type: "text", text: "Describe this image" },
type: "file", {
mediaType: "image/png", type: "file",
filename: "large-image.png", mediaType: "image/png",
data: image, filename: "large-image.png",
}, data: image,
], },
}, ],
] as ModelMessage[], },
tools: {}, ] as ModelMessage[],
}) tools: {},
},
ctx,
)
const capture = await request const capture = await request
expect(capture.url.pathname.endsWith("/responses")).toBe(true) expect(capture.url.pathname.endsWith("/responses")).toBe(true)
@@ -886,8 +901,8 @@ describe("session.llm.stream", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-3") const sessionID = SessionID.make("session-test-3")
const agent = { const agent = {
name: "test", name: "test",
@@ -907,15 +922,18 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.5") }, model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.5") },
} satisfies MessageV2.User } satisfies MessageV2.User
await drain({ await drain(
user, {
sessionID, user,
model: resolved, sessionID,
agent, model: resolved,
system: ["You are a helpful assistant."], agent,
messages: [{ role: "user", content: "Hello" }], system: ["You are a helpful assistant."],
tools: {}, messages: [{ role: "user", content: "Hello" }],
}) tools: {},
},
ctx,
)
const capture = await request const capture = await request
const body = capture.body const body = capture.body
@@ -1004,8 +1022,8 @@ describe("session.llm.stream", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id)) const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-anthropic-tools") const sessionID = SessionID.make("session-test-anthropic-tools")
const agent = { const agent = {
name: "test", name: "test",
@@ -1110,31 +1128,34 @@ describe("session.llm.stream", () => {
}, },
] as any[] ] as any[]
await drain({ await drain(
user, {
sessionID, user,
model: resolved, sessionID,
agent, model: resolved,
system: [], agent,
messages: await MessageV2.toModelMessages(input as any, resolved), system: [],
tools: { messages: await MessageV2.toModelMessages(input as any, resolved),
read: tool({ tools: {
description: "Stub read tool", read: tool({
inputSchema: z.object({ description: "Stub read tool",
filePath: z.string(), inputSchema: z.object({
filePath: z.string(),
}),
execute: async () => ({ output: "stub" }),
}), }),
execute: async () => ({ output: "stub" }), glob: tool({
}), description: "Stub glob tool",
glob: tool({ inputSchema: z.object({
description: "Stub glob tool", pattern: z.string(),
inputSchema: z.object({ path: z.string().optional(),
pattern: z.string(), }),
path: z.string().optional(), execute: async () => ({ output: "stub" }),
}), }),
execute: async () => ({ output: "stub" }), },
}),
}, },
}) ctx,
)
const capture = await request const capture = await request
const body = capture.body const body = capture.body
@@ -1245,8 +1266,8 @@ describe("session.llm.stream", () => {
await WithInstance.provide({ await WithInstance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async (ctx) => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-4") const sessionID = SessionID.make("session-test-4")
const agent = { const agent = {
name: "test", name: "test",
@@ -1266,15 +1287,18 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make(providerID), modelID: resolved.id }, model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
} satisfies MessageV2.User } satisfies MessageV2.User
await drain({ await drain(
user, {
sessionID, user,
model: resolved, sessionID,
agent, model: resolved,
system: ["You are a helpful assistant."], agent,
messages: [{ role: "user", content: "Hello" }], system: ["You are a helpful assistant."],
tools: {}, messages: [{ role: "user", content: "Hello" }],
}) tools: {},
},
ctx,
)
const capture = await request const capture = await request
const body = capture.body const body = capture.body
@@ -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")( provideInstance("/tmp/project")(
Effect.gen(function* () { Effect.gen(function* () {
const { requests, ctx } = makeCtx() const { requests, ctx } = makeCtx()
-1
View File
@@ -6,7 +6,6 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { LSP } from "@/lsp/lsp" import { LSP } from "@/lsp/lsp"
import { Permission } from "../../src/permission" import { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance"
import { MessageID, SessionID } from "../../src/session/schema" import { MessageID, SessionID } from "../../src/session/schema"
import { Tool } from "@/tool/tool" import { Tool } from "@/tool/tool"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncate"
-1
View File
@@ -10,7 +10,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { Git } from "@/git" import { Git } from "@/git"
import { LSP } from "@/lsp/lsp" import { LSP } from "@/lsp/lsp"
import { Permission } from "../../src/permission" import { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { Instruction } from "../../src/session/instruction" import { Instruction } from "../../src/session/instruction"
import { ReadTool } from "../../src/tool/read" import { ReadTool } from "../../src/tool/read"
@@ -5,7 +5,6 @@ import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import type { Permission } from "../../src/permission" import type { Permission } from "../../src/permission"
import type { Tool } from "@/tool/tool" import type { Tool } from "@/tool/tool"
import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill" import { SkillTool } from "../../src/tool/skill"
import { ToolRegistry } from "@/tool/registry" import { ToolRegistry } from "@/tool/registry"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
@@ -3,7 +3,6 @@ import { Effect, Layer } from "effect"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { WriteTool } from "../../src/tool/write" import { WriteTool } from "../../src/tool/write"
import { Instance } from "../../src/project/instance"
import { LSP } from "@/lsp/lsp" import { LSP } from "@/lsp/lsp"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"