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