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

This commit is contained in:
Shoubhit Dash 2026-05-15 23:05:44 +05:30 committed by GitHub
commit 0c9cfe923f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 939 additions and 1256 deletions

View file

@ -116,7 +116,7 @@ describe("my service", () => {
Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a manual runtime in each test.
- `tmpdirScoped(options?)` creates a scoped temp directory and cleans it up when the Effect scope closes.
- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it just runs an Effect with `Instance.current` bound to `dir`.
- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it runs an Effect with `InstanceRef` provided for `dir`.
- `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup.
- `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server.

View file

@ -3,7 +3,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect } from "effect"
import { fileURLToPath } from "url"
import { InstanceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@ -13,46 +12,28 @@ afterEach(async () => {
await disposeAllInstances()
})
// Regression for PR #25522: when an effectCmd handler does
// `yield* Effect.promise(async () => { ... await runPromise(svcMethod) ... })`,
// the inner runPromise creates a fresh fiber after `await` whose Effect context
// has lost the outer InstanceRef. Services that read `InstanceState.context`
// then fall back to `Instance.current` ALS, which must be installed at the JS
// callback boundary (Node ALS persists across awaits, Effect's fiber context
// does not). `it.instance` provides the loaded InstanceRef; the explicit
// Instance.restore mirrors effectCmd's load + ALS-restore wrap.
// Pins effect-cmd.ts directly: the pattern test below exercises the load +
// Instance.restore boundary via the shared `it.instance` fixture,
// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't
// fail it. This grep guards the actual production callsite.
it.live("effect-cmd.ts wraps the handler body in Instance.restore", () =>
it.live("effect-cmd.ts does not restore legacy instance ALS", () =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const source = yield* fs.readFileString(fileURLToPath(new URL("../../src/cli/effect-cmd.ts", import.meta.url)))
expect(source).toContain("Instance.restore(ctx")
expect(source).not.toContain("restore(ctx")
}),
)
it.instance(
"Instance.current reachable after await inside restored Effect.promise(async)",
"InstanceRef remains the handler context across Effect promise awaits",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* InstanceRef
if (!ctx) throw new Error("InstanceRef not provided")
const current = yield* Effect.promise(() =>
Instance.restore(ctx, async () => {
await Promise.resolve()
try {
return Instance.current
} catch {
return undefined
}
}),
)
const directory = yield* Effect.promise(async () => {
await Promise.resolve()
return ctx.directory
})
expect(current?.directory).toBe(test.directory)
expect(directory).toBe(test.directory)
}),
{ git: true },
)

View file

@ -6,7 +6,8 @@ import { ConfigManaged } from "@/config/managed"
import { ConfigParse } from "../../src/config/parse"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Instance } from "../../src/project/instance"
import { InstanceRef } from "../../src/effect/instance-ref"
import type { InstanceContext } from "../../src/project/instance-context"
import { WithInstance } from "../../src/project/with-instance"
import { Auth } from "../../src/auth"
import { Account } from "../../src/account/account"
@ -61,9 +62,20 @@ const layer = Config.layer.pipe(
const it = testEffect(layer)
const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
const save = (config: Config.Info) =>
Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
const provideCurrentInstance = <A, E, R>(effect: Effect.Effect<A, E, R>, ctx: InstanceContext) =>
effect.pipe(Effect.provideService(InstanceRef, ctx))
const load = (ctx: InstanceContext) =>
Effect.runPromise(
Config.Service.use((svc) => provideCurrentInstance(svc.get(), ctx)).pipe(Effect.scoped, Effect.provide(layer)),
)
const save = (config: Config.Info, ctx: InstanceContext) =>
Effect.runPromise(
Config.Service.use((svc) => provideCurrentInstance(svc.update(config), ctx)).pipe(
Effect.scoped,
Effect.provide(layer),
),
)
const saveGlobal = (config: Config.Info) =>
Effect.runPromise(
Config.Service.use((svc) => svc.updateGlobal(config)).pipe(
@ -76,10 +88,20 @@ const clear = async (wait = false) => {
await Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer)))
if (wait) await InstanceRuntime.disposeAllInstances()
}
const listDirs = () =>
Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer)))
const ready = () =>
Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer)))
const listDirs = (ctx: InstanceContext) =>
Effect.runPromise(
Config.Service.use((svc) => provideCurrentInstance(svc.directories(), ctx)).pipe(
Effect.scoped,
Effect.provide(layer),
),
)
const ready = (ctx: InstanceContext) =>
Effect.runPromise(
Config.Service.use((svc) => provideCurrentInstance(svc.waitForDependencies(), ctx)).pipe(
Effect.scoped,
Effect.provide(layer),
),
)
// Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
@ -116,11 +138,11 @@ async function check(map: (dir: string) => string) {
})
await WithInstance.provide({
directory: map(tmp.path),
fn: async () => {
const cfg = await load()
fn: async (ctx) => {
const cfg = await load(ctx)
expect(cfg.snapshot).toBe(true)
expect(Instance.directory).toBe(Filesystem.resolve(tmp.path))
expect(Instance.project.id).not.toBe(ProjectID.global)
expect(ctx.directory).toBe(Filesystem.resolve(tmp.path))
expect(ctx.project.id).not.toBe(ProjectID.global)
},
})
} finally {
@ -134,8 +156,8 @@ test("loads config with defaults when no files exist", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBeDefined()
},
})
@ -150,8 +172,8 @@ test("creates global jsonc config with schema when no global configs exist", asy
try {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await load()
fn: async (ctx) => {
await load(ctx)
},
})
@ -175,8 +197,8 @@ test("does not create global config when OPENCODE_CONFIG_DIR is set", async () =
try {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await load()
fn: async (ctx) => {
await load(ctx)
},
})
@ -201,8 +223,8 @@ test("loads JSON config file", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
},
@ -220,8 +242,8 @@ test("loads shell config field", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.shell).toBe("bash")
},
})
@ -242,8 +264,8 @@ test("updates config and preserves empty shell sentinel", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await save({ shell: "" })
fn: async (ctx) => {
await save({ shell: "" }, ctx)
const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "config.json"))
expect(writtenConfig.shell).toBe("")
@ -320,8 +342,8 @@ test("loads formatter boolean config", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.formatter).toBe(true)
},
})
@ -338,8 +360,8 @@ test("loads lsp boolean config", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.lsp).toBe(true)
},
})
@ -375,8 +397,8 @@ test("ignores legacy tui keys in opencode config", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("test/model")
expect((config as Record<string, unknown>).theme).toBeUndefined()
expect((config as Record<string, unknown>).tui).toBeUndefined()
@ -400,8 +422,8 @@ test("loads JSONC config file", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
},
@ -428,8 +450,8 @@ test("jsonc overrides json in the same directory", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("base")
expect(config.username).toBe("base")
},
@ -451,8 +473,8 @@ test("handles environment variable substitution", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("test-user")
},
})
@ -483,8 +505,8 @@ test("preserves env variables when adding $schema to config", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("secret_value")
// Read the file to verify the env variable was preserved
@ -580,8 +602,8 @@ test("handles file inclusion substitution", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("test-user")
},
})
@ -599,8 +621,8 @@ test("handles file inclusion with replacement tokens", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("const out = await Bun.$`echo hi`")
},
})
@ -617,9 +639,9 @@ test("validates config schema and throws on invalid fields", async () => {
})
await provideTestInstance({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
// Strict schema should throw an error for invalid fields
await expect(load()).rejects.toThrow()
await expect(load(ctx)).rejects.toThrow()
},
})
})
@ -632,8 +654,8 @@ test("throws error for invalid JSON", async () => {
})
await provideTestInstance({
directory: tmp.path,
fn: async () => {
await expect(load()).rejects.toThrow()
fn: async (ctx) => {
await expect(load(ctx)).rejects.toThrow()
},
})
})
@ -655,8 +677,8 @@ test("handles agent configuration", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test_agent"]).toEqual(
expect.objectContaining({
model: "test/model",
@ -686,8 +708,8 @@ test("treats agent variant as model-scoped setting (not provider option)", async
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
const agent = config.agent?.["test_agent"]
expect(agent?.variant).toBe("xhigh")
@ -716,8 +738,8 @@ test("handles command configuration", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.command?.["test_command"]).toEqual({
template: "test template",
description: "test command",
@ -741,8 +763,8 @@ test("migrates autoshare to share field", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.share).toBe("auto")
expect(config.autoshare).toBe(true)
},
@ -768,8 +790,8 @@ test("migrates mode field to agent field", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test_mode"]).toEqual({
model: "test/model",
temperature: 0.5,
@ -800,8 +822,8 @@ Test agent prompt`,
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test"]).toEqual(
expect.objectContaining({
name: "test",
@ -833,8 +855,8 @@ Ordered permissions`,
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(Object.keys(config.agent?.ordered?.permission ?? {})).toEqual(["bash", "*", "edit"])
},
})
@ -871,8 +893,8 @@ Nested agent prompt`,
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["helper"]).toMatchObject({
name: "helper",
@ -920,8 +942,8 @@ Nested command template`,
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.command?.["hello"]).toEqual({
description: "Test command",
@ -965,8 +987,8 @@ Nested command template`,
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.command?.["hello"]).toEqual({
description: "Test command",
@ -985,9 +1007,9 @@ test("updates config and writes to file", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
const newConfig = { model: "updated/model" }
await save(newConfig as any)
await save(newConfig as any, ctx)
const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json"))
expect(writtenConfig.model).toBe("updated/model")
@ -999,8 +1021,8 @@ test("gets config directories", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const dirs = await listDirs()
fn: async (ctx) => {
const dirs = await listDirs(ctx)
expect(dirs.length).toBeGreaterThanOrEqual(1)
},
})
@ -1029,8 +1051,8 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as
try {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await load()
fn: async (ctx) => {
await load(ctx)
},
})
} finally {
@ -1064,10 +1086,18 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
try {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)))
fn: async (ctx) => {
await Effect.runPromise(
Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(testLayer)),
Config.Service.use((svc) => svc.get().pipe(Effect.provideService(InstanceRef, ctx))).pipe(
Effect.scoped,
Effect.provide(testLayer),
),
)
await Effect.runPromise(
Config.Service.use((svc) => svc.waitForDependencies().pipe(Effect.provideService(InstanceRef, ctx))).pipe(
Effect.scoped,
Effect.provide(testLayer),
),
)
},
})
@ -1123,8 +1153,8 @@ test("resolves scoped npm plugins in config", async () => {
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
const pluginEntries = config.plugin ?? []
expect(pluginEntries).toContain("@scope/plugin")
},
@ -1161,8 +1191,8 @@ test("merges plugin arrays from global and local configs", async () => {
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
const plugins = config.plugin ?? []
// Should contain both global and local plugins
@ -1197,8 +1227,8 @@ Helper subagent prompt`,
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["helper"]).toMatchObject({
name: "helper",
model: "test/model",
@ -1236,8 +1266,8 @@ test("merges instructions arrays from global and local configs", async () => {
await WithInstance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
const instructions = config.instructions ?? []
expect(instructions).toContain("global-instructions.md")
@ -1275,8 +1305,8 @@ test("deduplicates duplicate instructions from global and local configs", async
await WithInstance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
const instructions = config.instructions ?? []
expect(instructions).toContain("global-only.md")
@ -1320,8 +1350,8 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
const plugins = config.plugin ?? []
// Should contain all unique plugins
@ -1369,8 +1399,8 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const cfg = await load()
fn: async (ctx) => {
const cfg = await load(ctx)
const plugins = cfg.plugin ?? []
const origins = cfg.plugin_origins ?? []
const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item))
@ -1410,8 +1440,8 @@ test("migrates legacy tools config to permissions - allow", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow",
read: "allow",
@ -1441,8 +1471,8 @@ test("migrates legacy tools config to permissions - deny", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({
bash: "deny",
webfetch: "deny",
@ -1471,8 +1501,8 @@ test("migrates legacy write tool to edit permission", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow",
})
@ -1503,8 +1533,8 @@ test("managed settings override user settings", async () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("managed/model")
expect(config.share).toBe("disabled")
expect(config.username).toBe("testuser")
@ -1531,8 +1561,8 @@ test("managed settings override project settings", async () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.autoupdate).toBe(false)
expect(config.disabled_providers).toEqual(["openai"])
},
@ -1551,8 +1581,8 @@ test("missing managed settings file is not an error", async () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("user/model")
},
})
@ -1578,8 +1608,8 @@ test("migrates legacy edit tool to edit permission", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({
edit: "deny",
})
@ -1607,8 +1637,8 @@ test("migrates legacy patch tool to edit permission", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow",
})
@ -1639,8 +1669,8 @@ test("migrates mixed legacy tools config", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow",
edit: "allow",
@ -1674,8 +1704,8 @@ test("merges legacy tools with existing permission config", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test"]?.permission).toEqual({
glob: "allow",
bash: "allow",
@ -1711,8 +1741,8 @@ test("permission config preserves user key order", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(Object.keys(config.permission!)).toEqual([
"*",
"edit",
@ -1794,8 +1824,8 @@ test("project config can override MCP server enabled status", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
// jira should be enabled (overridden by project config)
expect(config.mcp?.jira).toEqual({
type: "remote",
@ -1850,8 +1880,8 @@ test("MCP config deep merges preserving base config properties", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.mcp?.myserver).toEqual({
type: "remote",
url: "https://myserver.example.com/mcp",
@ -1901,8 +1931,8 @@ test("local .opencode config can override MCP from project config", async () =>
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.mcp?.docs?.enabled).toBe(true)
},
})
@ -2235,8 +2265,8 @@ describe("deduplicatePluginOrigins", () => {
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
const plugins = config.plugin ?? []
expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
@ -2267,8 +2297,8 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
// Project config should NOT be loaded - model should be default, not "project/model"
expect(config.model).not.toBe("project/model")
expect(config.username).not.toBe("project-user")
@ -2298,8 +2328,8 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const directories = await listDirs()
fn: async (ctx) => {
const directories = await listDirs(ctx)
// Project .opencode should NOT be in directories list
const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path))
expect(hasProjectOpencode).toBe(false)
@ -2322,9 +2352,9 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
// Should still get default config (from global or defaults)
const config = await load()
const config = await load(ctx)
expect(config).toBeDefined()
expect(config.username).toBeDefined()
},
@ -2364,10 +2394,10 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
// The relative instruction should be skipped without error
// We're mainly verifying this doesn't throw and the config loads
const config = await load()
const config = await load(ctx)
expect(config).toBeDefined()
// The instruction should have been skipped (warning logged)
// We can't easily test the warning was logged, but we verify
@ -2424,8 +2454,8 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
await WithInstance.provide({
directory: projectTmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
// Should load from OPENCODE_CONFIG_DIR, not project
expect(config.model).toBe("configdir/model")
},
@ -2459,8 +2489,8 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("test_api_key_12345")
},
})
@ -2493,8 +2523,8 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("secret_key_from_file")
},
})

View file

@ -139,7 +139,18 @@ async function initGitRepo(dir: string) {
await $`git commit -m "base"`.cwd(dir).quiet()
}
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, Workspace.Service>) => AppRuntime.runPromise(effect)
function currentInstance() {
try {
return context.use()
} catch {
return undefined
}
}
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, Workspace.Service>) => {
const ctx = currentInstance()
return AppRuntime.runPromise(ctx ? effect.pipe(Effect.provideService(InstanceRef, ctx)) : effect)
}
const createWorkspace = (input: Workspace.CreateInput) =>
runWorkspace(Workspace.Service.use((workspace) => workspace.create(input)))
const warpWorkspaceSession = (input: Workspace.SessionWarpInput) =>
@ -917,7 +928,9 @@ describe("workspace CRUD", () => {
const previous = workspaceInfo(projectID, previousType)
insertWorkspace(previous)
registerAdapter(projectID, previousType, localAdapter(workspaceTmp.path, { createDir: false }).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
const session = await AppRuntime.runPromise(
SessionNs.Service.use((svc) => svc.create({})).pipe(Effect.provideService(InstanceRef, instance)),
)
attachSessionToWorkspace(session.id, previous.id)
const workspaceCtx = await AppRuntime.runPromise(

View file

@ -3,7 +3,6 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { $ } from "bun"
import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"

View file

@ -11,9 +11,9 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import type { Config } from "@/config/config"
import { InstanceRef } from "../../src/effect/instance-ref"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import type { InstanceContext } from "../../src/project/instance-context"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { InstanceStore } from "../../src/project/instance-store"
import { Instance } from "../../src/project/instance"
import { TestLLMServer } from "../lib/llm-server"
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
@ -24,11 +24,15 @@ const testInstanceRuntime = ManagedRuntime.make(
const runTestInstanceStore = <A>(fn: (store: InstanceStore.Interface) => Effect.Effect<A>) =>
testInstanceRuntime.runPromise(InstanceStore.Service.use(fn))
export async function provideTestInstance<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) {
export async function provideTestInstance<R>(input: {
directory: string
init?: Effect.Effect<void>
fn: (ctx: InstanceContext) => R
}) {
const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory }))
try {
if (input.init) await testInstanceRuntime.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx)))
return await Instance.restore(ctx, () => input.fn())
return await input.fn(ctx)
} finally {
await runTestInstanceStore((store) => store.dispose(ctx))
}
@ -157,9 +161,7 @@ export const provideInstance =
Effect.contextWith((services: Context.Context<R>) =>
Effect.promise<A>(async () => {
const ctx = await runTestInstanceStore((store) => store.load({ directory }))
return Instance.restore(ctx, () =>
Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))),
)
return Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx)))
}),
)

View file

@ -4,7 +4,6 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture"
import { LSPClient } from "@/lsp/client"
import * as LSPServer from "@/lsp/server"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import * as Log from "@opencode-ai/core/util/log"
@ -28,12 +27,13 @@ describe("LSPClient interop", () => {
const client = await WithInstance.provide({
directory: process.cwd(),
fn: () =>
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
})
@ -51,12 +51,13 @@ describe("LSPClient interop", () => {
const client = await WithInstance.provide({
directory: process.cwd(),
fn: () =>
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
})
@ -74,12 +75,13 @@ describe("LSPClient interop", () => {
const client = await WithInstance.provide({
directory: process.cwd(),
fn: () =>
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
})
@ -97,12 +99,13 @@ describe("LSPClient interop", () => {
const client = await WithInstance.provide({
directory: process.cwd(),
fn: () =>
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
})
@ -124,7 +127,7 @@ describe("LSPClient interop", () => {
const client = await WithInstance.provide({
directory: process.cwd(),
fn: () =>
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: {
@ -133,6 +136,7 @@ describe("LSPClient interop", () => {
},
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
})
@ -153,12 +157,13 @@ describe("LSPClient interop", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.notify.open({ path: file })
@ -196,12 +201,13 @@ describe("LSPClient interop", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
const version = await client.notify.open({ path: file })
@ -242,12 +248,13 @@ describe("LSPClient interop", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
const version = await client.notify.open({ path: file })
@ -289,12 +296,13 @@ describe("LSPClient interop", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
@ -337,12 +345,13 @@ describe("LSPClient interop", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
@ -390,12 +399,13 @@ describe("LSPClient interop", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
@ -454,12 +464,13 @@ describe("LSPClient interop", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {

View file

@ -15,10 +15,10 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Workspace } from "../../src/control-plane/workspace"
import { Plugin } from "../../src/plugin/index"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { Vcs } from "../../src/project/vcs"
import { InstanceState } from "../../src/effect/instance-state"
import { Session } from "../../src/session/session"
import { SessionPrompt } from "../../src/session/prompt"
import { SyncEvent } from "../../src/sync"
@ -116,11 +116,12 @@ describe("plugin.workspace", () => {
const plugin = yield* Plugin.Service
yield* plugin.init()
const workspace = yield* Workspace.Service
const ctx = yield* InstanceState.context
const info = yield* workspace.create({
type,
branch: null,
extra: { key: "value" },
projectID: Instance.project.id,
projectID: ctx.project.id,
})
expect(info.type).toBe(type)

View file

@ -4,9 +4,8 @@ import { Deferred, Effect, Fiber, Layer } from "effect"
import { InstanceRef } from "../../src/effect/instance-ref"
import { registerDisposer } from "../../src/effect/instance-registry"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
let bootstrapRun: Effect.Effect<void> = Effect.void
@ -37,7 +36,7 @@ const registerDisposerScoped = (disposer: (directory: string) => Promise<void>)
)
describe("InstanceStore", () => {
it.live("loads instance context without installing ALS for the caller", () =>
it.live("loads instance context", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
@ -45,7 +44,6 @@ describe("InstanceStore", () => {
expect(ctx.directory).toBe(dir)
expect(ctx.worktree).toBe(dir)
expect(() => Instance.current).toThrow()
}),
)
@ -63,7 +61,6 @@ describe("InstanceStore", () => {
yield* store.load({ directory: dir })
expect(initializedDirectory).toBe(dir)
expect(() => Instance.current).toThrow()
}),
)
@ -245,20 +242,4 @@ describe("InstanceStore", () => {
expect(disposed).toEqual([dir1, dir2])
}),
)
it.instance(
"provides legacy Promise callers with instance ALS",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* InstanceRef
if (!ctx) throw new Error("InstanceRef not provided")
const directory = yield* Effect.promise(() => Promise.resolve(Instance.restore(ctx, () => Instance.directory)))
expect(directory).toBe(test.directory)
expect(() => Instance.current).toThrow()
}),
{ git: true },
)
})

View file

@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { Git } from "../../src/git"
import { Instance } from "../../src/project/instance"
import { InstanceRef } from "../../src/effect/instance-ref"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { Worktree } from "../../src/worktree"
import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
@ -41,7 +41,10 @@ const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () {
const removeCreatedWorktree = (directory: string) =>
Effect.gen(function* () {
const svc = yield* Worktree.Service
const ctx = yield* Effect.sync(() => Instance.current).pipe(provideInstance(directory))
const ctx = yield* Effect.gen(function* () {
return yield* InstanceRef
}).pipe(provideInstance(directory))
if (!ctx) return yield* Effect.die(new Error("missing test instance"))
yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx))
const ok = yield* svc.remove({ directory })
if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${directory}`))

View file

@ -1,10 +1,10 @@
import { test, expect, describe } from "bun:test"
import { afterEach, test, expect, describe } from "bun:test"
import path from "path"
import { unlink } from "fs/promises"
import { ProviderID } from "../../src/provider/schema"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import type { InstanceContext } from "../../src/project/instance-context"
import { WithInstance } from "../../src/project/with-instance"
import { Provider } from "@/provider/provider"
import { Env } from "../../src/env"
@ -12,17 +12,37 @@ import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { Effect } from "effect"
import { AppRuntime } from "../../src/effect/app-runtime"
import { InstanceRef } from "../../src/effect/instance-ref"
import { makeRuntime } from "../../src/effect/run-service"
const env = makeRuntime(Env.Service, Env.defaultLayer)
const set = (k: string, v: string) => env.runSync((svc) => svc.set(k, v))
const originalEnv = new Map<string, string | undefined>()
async function list() {
function rememberEnv(k: string) {
if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
}
const set = (ctx: InstanceContext, k: string, v: string) => {
rememberEnv(k)
process.env[k] = v
return env.runSync((svc) => svc.set(k, v).pipe(Effect.provideService(InstanceRef, ctx)))
}
afterEach(async () => {
for (const [key, value] of originalEnv) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
originalEnv.clear()
await disposeAllInstances()
})
async function list(ctx: InstanceContext) {
return AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.list()
}),
}).pipe(Effect.provideService(InstanceRef, ctx)),
)
}
@ -46,10 +66,10 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("AWS_REGION", "us-east-1")
set("AWS_PROFILE", "default")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_REGION", "us-east-1")
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
},
@ -69,10 +89,10 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async ()
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("AWS_REGION", "eu-west-1")
set("AWS_PROFILE", "default")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_REGION", "eu-west-1")
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
},
@ -122,11 +142,11 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("AWS_PROFILE", "")
set("AWS_ACCESS_KEY_ID", "")
set("AWS_BEARER_TOKEN_BEDROCK", "")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "")
set(ctx, "AWS_ACCESS_KEY_ID", "")
set(ctx, "AWS_BEARER_TOKEN_BEDROCK", "")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
},
@ -166,10 +186,10 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("AWS_PROFILE", "default")
set("AWS_ACCESS_KEY_ID", "test-key-id")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
set(ctx, "AWS_ACCESS_KEY_ID", "test-key-id")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
},
@ -196,9 +216,9 @@ test("Bedrock: includes custom endpoint in options when specified", async () =>
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("AWS_PROFILE", "default")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe(
"https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
@ -227,12 +247,12 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async ()
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
set("AWS_PROFILE", "")
set("AWS_ACCESS_KEY_ID", "")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
set(ctx, "AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
set(ctx, "AWS_PROFILE", "")
set(ctx, "AWS_ACCESS_KEY_ID", "")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
},
@ -268,9 +288,9 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () =>
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("AWS_PROFILE", "default")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
// The model should exist with the us. prefix
expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
@ -303,9 +323,9 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("AWS_PROFILE", "default")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
@ -337,9 +357,9 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () =>
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("AWS_PROFILE", "default")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
@ -371,9 +391,9 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("AWS_PROFILE", "default")
const providers = await list()
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
// Non-prefixed model should still be registered
expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()

View file

@ -8,7 +8,6 @@ export {}
// import { ProviderID, ModelID } from "../../src/provider/schema"
// import { tmpdir } from "../fixture/fixture"
// import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
// import { Provider } from "@/provider/provider"
// import { Env } from "../../src/env"

View file

@ -1,10 +1,10 @@
import { test, expect } from "bun:test"
import { afterEach, test, expect } from "bun:test"
import { mkdir, unlink } from "fs/promises"
import path from "path"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { Global } from "@opencode-ai/core/global"
import { Instance } from "../../src/project/instance"
import type { InstanceContext } from "../../src/project/instance-context"
import { WithInstance } from "../../src/project/with-instance"
import { Plugin } from "../../src/plugin/index"
import { ModelsDev } from "@opencode-ai/core/models"
@ -14,6 +14,7 @@ import { Filesystem } from "@/util/filesystem"
import { Env } from "../../src/env"
import { Effect, Layer } from "effect"
import { AppRuntime } from "../../src/effect/app-runtime"
import { InstanceRef } from "../../src/effect/instance-ref"
import { makeRuntime } from "../../src/effect/run-service"
import { testEffect } from "../lib/effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
@ -22,8 +23,31 @@ import { Auth } from "@/auth"
import { RuntimeFlags } from "@/effect/runtime-flags"
const env = makeRuntime(Env.Service, Env.defaultLayer)
const set = (k: string, v: string) => env.runSync((svc) => svc.set(k, v))
const remove = (k: string) => env.runSync((svc) => svc.remove(k))
const originalEnv = new Map<string, string | undefined>()
function rememberEnv(k: string) {
if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
}
const set = (ctx: InstanceContext, k: string, v: string) => {
rememberEnv(k)
process.env[k] = v
return env.runSync((svc) => svc.set(k, v).pipe(Effect.provideService(InstanceRef, ctx)))
}
const remove = (ctx: InstanceContext, k: string) => {
rememberEnv(k)
delete process.env[k]
return env.runSync((svc) => svc.remove(k).pipe(Effect.provideService(InstanceRef, ctx)))
}
afterEach(async () => {
for (const [key, value] of originalEnv) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
originalEnv.clear()
await disposeAllInstances()
})
const providerLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Provider.layer.pipe(
@ -36,41 +60,41 @@ const providerLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.provide(RuntimeFlags.layer(flags)),
)
async function run<A, E>(fn: (provider: Provider.Interface) => Effect.Effect<A, E, never>) {
async function run<A, E>(ctx: InstanceContext, fn: (provider: Provider.Interface) => Effect.Effect<A, E, never>) {
return AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* fn(provider)
}),
}).pipe(Effect.provideService(InstanceRef, ctx)),
)
}
async function list() {
return run((provider) => provider.list())
async function list(ctx: InstanceContext) {
return run(ctx, (provider) => provider.list())
}
async function getProvider(providerID: ProviderID) {
return run((provider) => provider.getProvider(providerID))
async function getProvider(providerID: ProviderID, ctx: InstanceContext) {
return run(ctx, (provider) => provider.getProvider(providerID))
}
async function getModel(providerID: ProviderID, modelID: ModelID) {
return run((provider) => provider.getModel(providerID, modelID))
async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) {
return run(ctx, (provider) => provider.getModel(providerID, modelID))
}
async function getLanguage(model: Provider.Model) {
return run((provider) => provider.getLanguage(model))
async function getLanguage(model: Provider.Model, ctx: InstanceContext) {
return run(ctx, (provider) => provider.getLanguage(model))
}
async function closest(providerID: ProviderID, query: string[]) {
return run((provider) => provider.closest(providerID, query))
async function closest(providerID: ProviderID, query: string[], ctx: InstanceContext) {
return run(ctx, (provider) => provider.closest(providerID, query))
}
async function getSmallModel(providerID: ProviderID) {
return run((provider) => provider.getSmallModel(providerID))
async function getSmallModel(providerID: ProviderID, ctx: InstanceContext) {
return run(ctx, (provider) => provider.getSmallModel(providerID))
}
async function defaultModel() {
return run((provider) => provider.defaultModel())
async function defaultModel(ctx: InstanceContext) {
return run(ctx, (provider) => provider.defaultModel())
}
async function markPluginDependenciesReady(dir: string) {
@ -125,9 +149,9 @@ test("provider loaded from env variable", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
// Provider should retain its connection source even if custom loaders
// merge additional options.
@ -157,8 +181,8 @@ test("provider loaded from config with apiKey option", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
},
})
@ -178,9 +202,9 @@ test("disabled_providers excludes provider", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeUndefined()
},
})
@ -200,10 +224,10 @@ test("enabled_providers restricts to only listed providers", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
set("OPENAI_API_KEY", "test-openai-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
set(ctx, "OPENAI_API_KEY", "test-openai-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeUndefined()
},
@ -228,9 +252,9 @@ test("model whitelist filters models for provider", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
expect(models).toContain("claude-sonnet-4-20250514")
@ -257,9 +281,9 @@ test("model blacklist excludes specific models", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
expect(models).not.toContain("claude-sonnet-4-20250514")
@ -290,9 +314,9 @@ test("custom model alias via config", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias")
@ -334,8 +358,8 @@ test("custom provider with npm package", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
expect(providers[ProviderID.make("custom-provider")]).toBeDefined()
expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider")
expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined()
@ -411,8 +435,8 @@ test("custom DeepSeek openai-compatible model defaults interleaved reasoning fie
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
const provider = providers[ProviderID.make("custom-provider")]
expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" })
@ -445,9 +469,9 @@ test("env variable takes precedence, config merges options", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "env-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "env-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
// Config options should be merged
expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
@ -469,13 +493,13 @@ test("getModel returns model for valid provider/model", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx)
expect(model).toBeDefined()
expect(String(model.providerID)).toBe("anthropic")
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
const language = await getLanguage(model)
const language = await getLanguage(model, ctx)
expect(language).toBeDefined()
},
})
@ -494,9 +518,9 @@ test("getModel throws ModelNotFoundError for invalid model", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"), ctx)).rejects.toThrow()
},
})
})
@ -514,8 +538,8 @@ test("getModel throws ModelNotFoundError for invalid provider", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow()
fn: async (ctx) => {
expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"), ctx)).rejects.toThrow()
},
})
})
@ -545,9 +569,9 @@ test("defaultModel returns first available model when no config set", async () =
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const model = await defaultModel()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await defaultModel(ctx)
expect(model.providerID).toBeDefined()
expect(model.modelID).toBeDefined()
},
@ -568,9 +592,9 @@ test("defaultModel respects config model setting", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const model = await defaultModel()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await defaultModel(ctx)
expect(String(model.providerID)).toBe("anthropic")
expect(String(model.modelID)).toBe("claude-sonnet-4-20250514")
},
@ -701,9 +725,9 @@ test("closest finds model by partial match", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const result = await closest(ProviderID.anthropic, ["sonnet-4"])
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const result = await closest(ProviderID.anthropic, ["sonnet-4"], ctx)
expect(result).toBeDefined()
expect(String(result?.providerID)).toBe("anthropic")
expect(String(result?.modelID)).toContain("sonnet-4")
@ -724,8 +748,8 @@ test("closest returns undefined for nonexistent provider", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const result = await closest(ProviderID.make("nonexistent"), ["model"])
fn: async (ctx) => {
const result = await closest(ProviderID.make("nonexistent"), ["model"], ctx)
expect(result).toBeUndefined()
},
})
@ -754,12 +778,12 @@ test("getModel uses realIdByKey for aliased models", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined()
const model = await getModel(ProviderID.anthropic, ModelID.make("my-sonnet"))
const model = await getModel(ProviderID.anthropic, ModelID.make("my-sonnet"), ctx)
expect(model).toBeDefined()
expect(String(model.id)).toBe("my-sonnet")
expect(model.name).toBe("My Sonnet Alias")
@ -798,8 +822,8 @@ test("provider api field sets model api.url", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
// api field is stored on model.api.url, used by getSDK to set baseURL
expect(providers[ProviderID.make("custom-api")].models["model-1"].api.url).toBe("https://api.example.com/v1")
},
@ -838,8 +862,8 @@ test("explicit baseURL overrides api field", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
expect(providers[ProviderID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1")
},
})
@ -867,9 +891,9 @@ test("model inherits properties from existing database model", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.name).toBe("Custom Name for Sonnet")
expect(model.capabilities.toolcall).toBe(true)
@ -893,9 +917,9 @@ test("disabled_providers prevents loading even with env var", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("OPENAI_API_KEY", "test-openai-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "OPENAI_API_KEY", "test-openai-key")
const providers = await list(ctx)
expect(providers[ProviderID.openai]).toBeUndefined()
},
})
@ -915,10 +939,10 @@ test("enabled_providers with empty array allows no providers", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
set("OPENAI_API_KEY", "test-openai-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
set(ctx, "OPENAI_API_KEY", "test-openai-key")
const providers = await list(ctx)
expect(Object.keys(providers).length).toBe(0)
},
})
@ -943,9 +967,9 @@ test("whitelist and blacklist can be combined", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
expect(models).toContain("claude-sonnet-4-20250514")
@ -983,8 +1007,8 @@ test("model modalities default correctly", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
const model = providers[ProviderID.make("test-provider")].models["test-model"]
expect(model.capabilities.input.text).toBe(true)
expect(model.capabilities.output.text).toBe(true)
@ -1026,8 +1050,8 @@ test("model with custom cost values", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
const model = providers[ProviderID.make("test-provider")].models["test-model"]
expect(model.cost.input).toBe(5)
expect(model.cost.output).toBe(15)
@ -1050,9 +1074,9 @@ test("getSmallModel returns appropriate small model", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const model = await getSmallModel(ProviderID.anthropic)
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await getSmallModel(ProviderID.anthropic, ctx)
expect(model).toBeDefined()
expect(model?.id).toContain("haiku")
},
@ -1073,9 +1097,9 @@ test("getSmallModel respects config small_model override", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const model = await getSmallModel(ProviderID.anthropic)
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await getSmallModel(ProviderID.anthropic, ctx)
expect(model).toBeDefined()
expect(String(model?.providerID)).toBe("anthropic")
expect(String(model?.id)).toBe("claude-sonnet-4-20250514")
@ -1097,9 +1121,9 @@ test("getSmallModel ignores invalid config small_model", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
expect(await getSmallModel(ProviderID.anthropic)).toBeUndefined()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
expect(await getSmallModel(ProviderID.anthropic, ctx)).toBeUndefined()
},
})
})
@ -1140,10 +1164,10 @@ test("multiple providers can be configured simultaneously", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-anthropic-key")
set("OPENAI_API_KEY", "test-openai-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key")
set(ctx, "OPENAI_API_KEY", "test-openai-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeDefined()
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
@ -1183,8 +1207,8 @@ test("provider with custom npm package", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
expect(providers[ProviderID.make("local-llm")]).toBeDefined()
expect(providers[ProviderID.make("local-llm")].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible")
expect(providers[ProviderID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1")
@ -1217,9 +1241,9 @@ test("model alias name defaults to alias key when id differs", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet")
},
})
@ -1255,9 +1279,9 @@ test("provider with multiple env var options only includes apiKey when single en
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("MULTI_ENV_KEY_1", "test-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "MULTI_ENV_KEY_1", "test-key")
const providers = await list(ctx)
expect(providers[ProviderID.make("multi-env")]).toBeDefined()
// When multiple env options exist, key should NOT be auto-set
expect(providers[ProviderID.make("multi-env")].key).toBeUndefined()
@ -1295,9 +1319,9 @@ test("provider with single env var includes apiKey automatically", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("SINGLE_ENV_KEY", "my-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "SINGLE_ENV_KEY", "my-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.make("single-env")]).toBeDefined()
// Single env option should auto-set key
expect(providers[ProviderID.make("single-env")].key).toBe("my-api-key")
@ -1330,9 +1354,9 @@ test("model cost overrides existing cost values", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.cost.input).toBe(999)
expect(model.cost.output).toBe(888)
@ -1378,8 +1402,8 @@ test("completely new provider not in database can be configured", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
expect(providers[ProviderID.make("brand-new-provider")]).toBeDefined()
expect(providers[ProviderID.make("brand-new-provider")].name).toBe("Brand New")
const model = providers[ProviderID.make("brand-new-provider")].models["new-model"]
@ -1407,11 +1431,11 @@ test("disabled_providers and enabled_providers interaction", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-anthropic")
set("OPENAI_API_KEY", "test-openai")
set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-anthropic")
set(ctx, "OPENAI_API_KEY", "test-openai")
set(ctx, "GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
const providers = await list(ctx)
// anthropic: in enabled, not in disabled = allowed
expect(providers[ProviderID.anthropic]).toBeDefined()
// openai: in enabled, but also in disabled = NOT allowed
@ -1450,8 +1474,8 @@ test("model with tool_call false", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
expect(providers[ProviderID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false)
},
})
@ -1485,8 +1509,8 @@ test("model defaults tool_call to true when not specified", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
expect(providers[ProviderID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true)
},
})
@ -1524,8 +1548,8 @@ test("model headers are preserved", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
const model = providers[ProviderID.make("headers-provider")].models["model"]
expect(model.headers).toEqual({
"X-Custom-Header": "custom-value",
@ -1563,10 +1587,10 @@ test("provider env fallback - second env var used if first missing", async () =>
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
fn: async (ctx) => {
// Only set fallback, not primary
set("FALLBACK_KEY", "fallback-api-key")
const providers = await list()
set(ctx, "FALLBACK_KEY", "fallback-api-key")
const providers = await list(ctx)
// Provider should load because fallback env var is set
expect(providers[ProviderID.make("fallback-env")]).toBeDefined()
},
@ -1586,10 +1610,10 @@ test("getModel returns consistent results", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx)
const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx)
expect(model1.providerID).toEqual(model2.providerID)
expect(model1.id).toEqual(model2.id)
expect(model1).toEqual(model2)
@ -1625,8 +1649,8 @@ test("provider name defaults to id when not in database", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
expect(providers[ProviderID.make("my-custom-id")].name).toBe("my-custom-id")
},
})
@ -1645,10 +1669,10 @@ test("ModelNotFoundError includes suggestions for typos", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
try {
await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet
await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4"), ctx) // typo: sonet instead of sonnet
expect(true).toBe(false) // Should not reach here
} catch (e: any) {
expect(e.suggestions).toBeDefined()
@ -1671,10 +1695,10 @@ test("ModelNotFoundError for provider includes suggestions", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
try {
await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic
await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4"), ctx) // typo: antropic
expect(true).toBe(false) // Should not reach here
} catch (e: any) {
expect(e.suggestions).toBeDefined()
@ -1697,10 +1721,10 @@ test("ModelNotFoundError suggests catalog models for unloaded providers", async
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
remove("OPENCODE_API_KEY")
fn: async (ctx) => {
remove(ctx, "OPENCODE_API_KEY")
try {
await getModel(ProviderID.opencode, ModelID.make("claude-haiku-fake-model"))
await getModel(ProviderID.opencode, ModelID.make("claude-haiku-fake-model"), ctx)
throw new Error("expected model lookup to fail")
} catch (e) {
if (!Provider.ModelNotFoundError.isInstance(e)) throw e
@ -1723,8 +1747,8 @@ test("getProvider returns undefined for nonexistent provider", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const provider = await getProvider(ProviderID.make("nonexistent"))
fn: async (ctx) => {
const provider = await getProvider(ProviderID.make("nonexistent"), ctx)
expect(provider).toBeUndefined()
},
})
@ -1743,9 +1767,9 @@ test("getProvider returns provider info", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const provider = await getProvider(ProviderID.anthropic)
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const provider = await getProvider(ProviderID.anthropic, ctx)
expect(provider).toBeDefined()
expect(String(provider?.id)).toBe("anthropic")
},
@ -1765,9 +1789,9 @@ test("closest returns undefined when no partial match found", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"])
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"], ctx)
expect(result).toBeUndefined()
},
})
@ -1786,10 +1810,10 @@ test("closest checks multiple query terms in order", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
// First term won't match, second will
const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"])
const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"], ctx)
expect(result).toBeDefined()
expect(result?.modelID).toContain("haiku")
},
@ -1824,8 +1848,8 @@ test("model limit defaults to zero when not specified", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
const model = providers[ProviderID.make("no-limit")].models["model"]
expect(model.limit.context).toBe(0)
expect(model.limit.output).toBe(0)
@ -1856,9 +1880,9 @@ test("provider options are deeply merged", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
// Custom options should be merged
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
expect(providers[ProviderID.anthropic].options.headers["X-Custom"]).toBe("custom-value")
@ -1888,8 +1912,8 @@ test("hosted nvidia provider adds billing origin header", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
expect(providers[ProviderID.make("nvidia")].options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
@ -1920,8 +1944,8 @@ test("custom nvidia baseURL adds billing origin header", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
expect(providers[ProviderID.make("nvidia")].options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
@ -1955,8 +1979,8 @@ test("explicit nvidia billing origin header is preserved", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
expect(providers[ProviderID.make("nvidia")].options.headers["X-BILLING-INVOKE-ORIGIN"]).toBe("CustomOrigin")
},
})
@ -1986,9 +2010,9 @@ test("custom model inherits npm package from models.dev provider config", async
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("OPENAI_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "OPENAI_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.openai].models["my-custom-model"]
expect(model).toBeDefined()
expect(model.api.npm).toBe("@ai-sdk/openai")
@ -2019,9 +2043,9 @@ test("custom model inherits api.url from models.dev provider", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("OPENROUTER_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "OPENROUTER_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.openrouter]).toBeDefined()
// New model not in database should inherit api.url from provider
@ -2150,9 +2174,9 @@ test("model variants are generated for reasoning models", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
// Claude sonnet 4 has reasoning capability
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.capabilities.reasoning).toBe(true)
@ -2186,9 +2210,9 @@ test("model variants can be disabled via config", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined()
@ -2227,9 +2251,9 @@ test("model variants can be customized via config", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
@ -2264,9 +2288,9 @@ test("disabled key is stripped from variant config", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["max"]).toBeDefined()
expect(model.variants!["max"].disabled).toBeUndefined()
@ -2300,9 +2324,9 @@ test("all variants can be disabled via config", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBe(0)
@ -2336,9 +2360,9 @@ test("variant config merges with generated variants", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
// Should have both the generated thinking config and the custom option
@ -2372,9 +2396,9 @@ test("variants filtered in second pass for database models", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("OPENAI_API_KEY", "test-api-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "OPENAI_API_KEY", "test-api-key")
const providers = await list(ctx)
const model = providers[ProviderID.openai].models["gpt-5"]
expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined()
@ -2419,8 +2443,8 @@ test("custom model with variants enabled and disabled", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
fn: async (ctx) => {
const providers = await list(ctx)
const model = providers[ProviderID.make("custom-reasoning")].models["reasoning-model"]
expect(model.variants).toBeDefined()
// Enabled variants should exist
@ -2474,9 +2498,9 @@ test("Google Vertex: retains baseURL for custom proxy", async () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
const providers = await list()
fn: async (ctx) => {
set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
const providers = await list(ctx)
expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined()
expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1")
},
@ -2517,9 +2541,9 @@ test("Google Vertex: supports OpenAI compatible models", async () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
const providers = await list()
fn: async (ctx) => {
set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
const providers = await list(ctx)
const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"]
expect(model).toBeDefined()
@ -2541,11 +2565,11 @@ test("cloudflare-ai-gateway loads with env variables", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("CLOUDFLARE_ACCOUNT_ID", "test-account")
set("CLOUDFLARE_GATEWAY_ID", "test-gateway")
set("CLOUDFLARE_API_TOKEN", "test-token")
const providers = await list()
fn: async (ctx) => {
set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account")
set(ctx, "CLOUDFLARE_GATEWAY_ID", "test-gateway")
set(ctx, "CLOUDFLARE_API_TOKEN", "test-token")
const providers = await list(ctx)
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
},
})
@ -2571,11 +2595,11 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => {
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("CLOUDFLARE_ACCOUNT_ID", "test-account")
set("CLOUDFLARE_GATEWAY_ID", "test-gateway")
set("CLOUDFLARE_API_TOKEN", "test-token")
const providers = await list()
fn: async (ctx) => {
set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account")
set(ctx, "CLOUDFLARE_GATEWAY_ID", "test-gateway")
set(ctx, "CLOUDFLARE_API_TOKEN", "test-token")
const providers = await list(ctx)
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({
invoked_by: "test",
@ -2624,14 +2648,14 @@ test("plugin config providers persist after instance dispose", async () => {
const first = await WithInstance.provide({
directory: tmp.path,
fn: async () =>
fn: async (ctx) =>
AppRuntime.runPromise(
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const provider = yield* Provider.Service
yield* plugin.init()
return yield* provider.list()
}),
}).pipe(Effect.provideService(InstanceRef, ctx)),
),
})
expect(first[ProviderID.make("demo")]).toBeDefined()
@ -2641,7 +2665,7 @@ test("plugin config providers persist after instance dispose", async () => {
const second = await WithInstance.provide({
directory: tmp.path,
fn: async () => list(),
fn: async (ctx) => list(ctx),
})
expect(second[ProviderID.make("demo")]).toBeDefined()
expect(second[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined()
@ -2672,10 +2696,10 @@ test("plugin config enabled and disabled providers are honored", async () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("ANTHROPIC_API_KEY", "test-anthropic-key")
set("OPENAI_API_KEY", "test-openai-key")
const providers = await list()
fn: async (ctx) => {
set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key")
set(ctx, "OPENAI_API_KEY", "test-openai-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeUndefined()
},
@ -2696,7 +2720,7 @@ test("opencode loader keeps paid models when config apiKey is present", async ()
const none = await WithInstance.provide({
directory: base.path,
fn: async () => paid(await list()),
fn: async (ctx) => paid(await list(ctx)),
})
await using keyed = await tmpdir({
@ -2719,7 +2743,7 @@ test("opencode loader keeps paid models when config apiKey is present", async ()
const keyedCount = await WithInstance.provide({
directory: keyed.path,
fn: async () => paid(await list()),
fn: async (ctx) => paid(await list(ctx)),
})
expect(none).toBe(0)
@ -2740,7 +2764,7 @@ test("opencode loader keeps paid models when auth exists", async () => {
const none = await WithInstance.provide({
directory: base.path,
fn: async () => paid(await list()),
fn: async (ctx) => paid(await list(ctx)),
})
await using keyed = await tmpdir({
@ -2774,7 +2798,7 @@ test("opencode loader keeps paid models when auth exists", async () => {
const keyedCount = await WithInstance.provide({
directory: keyed.path,
fn: async () => paid(await list()),
fn: async (ctx) => paid(await list(ctx)),
})
expect(none).toBe(0)

View file

@ -1,7 +1,7 @@
import { afterEach, expect } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer, Queue } from "effect"
import { Question } from "../../src/question"
import { Instance } from "../../src/project/instance"
import { InstanceRef } from "../../src/effect/instance-ref"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { QuestionID } from "../../src/question/schema"
import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture"
@ -404,7 +404,10 @@ it.live("pending question rejects on instance dispose", () =>
}).pipe(provideInstance(dir), Effect.forkScoped)
expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1)
const ctx = yield* Effect.sync(() => Instance.current).pipe(provideInstance(dir))
const ctx = yield* Effect.gen(function* () {
return yield* InstanceRef
}).pipe(provideInstance(dir))
if (!ctx) return yield* Effect.die(new Error("missing test instance"))
yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx))
const exit = yield* Fiber.await(fiber)

View file

@ -1,11 +1,12 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Bus } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { AppRuntime } from "../../src/effect/app-runtime"
import { InstanceRef } from "../../src/effect/instance-ref"
import { Server } from "../../src/server/server"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { Event as ServerEvent } from "../../src/server/event"
import * as Log from "@opencode-ai/core/util/log"
import { Schema } from "effect"
import { Effect, Schema } from "effect"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, reloadTestInstance, tmpdir } from "../fixture/fixture"
@ -108,7 +109,9 @@ describe("event HttpApi", () => {
const next = readEvent(reader)
const ctx = await reloadTestInstance({ directory: tmp.path })
await Instance.restore(ctx, () => Bus.publish(ServerEvent.Connected, {}))
await AppRuntime.runPromise(
Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {})).pipe(Effect.provideService(InstanceRef, ctx)),
)
expect(await next).toMatchObject({ type: "server.connected", properties: {} })
} finally {

View file

@ -3,7 +3,6 @@ export type Runtime = {
HttpApiApp: (typeof import("../../../src/server/routes/instance/httpapi/server"))["HttpApiApp"]
AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"]
InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"]
Instance: (typeof import("../../../src/project/instance"))["Instance"]
InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"]
Session: (typeof import("../../../src/session/session"))["Session"]
Todo: (typeof import("../../../src/session/todo"))["Todo"]
@ -23,7 +22,6 @@ export function runtime() {
const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server")
const appRuntime = await import("../../../src/effect/app-runtime")
const instanceRef = await import("../../../src/effect/instance-ref")
const instance = await import("../../../src/project/instance")
const instanceStore = await import("../../../src/project/instance-store")
const session = await import("../../../src/session/session")
const todo = await import("../../../src/session/todo")
@ -37,7 +35,6 @@ export function runtime() {
HttpApiApp: httpApiServer.HttpApiApp,
AppLayer: appRuntime.AppLayer,
InstanceRef: instanceRef.InstanceRef,
Instance: instance.Instance,
InstanceStore: instanceStore.InstanceStore,
Session: session.Session,
Todo: todo.Todo,

View file

@ -3,7 +3,6 @@ import { Context } from "effect"
import path from "path"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { Instance } from "../../src/project/instance"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"

View file

@ -10,7 +10,6 @@ import { WorkspaceID } from "../../src/control-plane/schema"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"
import { InstanceLayer } from "../../src/project/instance-layer"
import { Project } from "../../src/project/project"
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"

View file

@ -1,7 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test"
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { PtyID } from "../../src/pty/schema"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import * as Log from "@opencode-ai/core/util/log"

View file

@ -1,7 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test"
import { ConfigProvider, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { Instance } from "../../src/project/instance"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"

View file

@ -13,7 +13,6 @@ import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
@ -71,7 +70,7 @@ function listedAdapter(directory: string, type: string): WorkspaceAdapter {
},
async create() {},
async remove() {},
list() {
list(context) {
return [
{
type,
@ -79,7 +78,7 @@ function listedAdapter(directory: string, type: string): WorkspaceAdapter {
branch: "listed/main",
directory,
extra: { listed: true },
projectID: Instance.project.id,
projectID: context?.instance?.project.id ?? missingAdapterContext(),
},
]
},
@ -92,6 +91,10 @@ function listedAdapter(directory: string, type: string): WorkspaceAdapter {
}
}
function missingAdapterContext(): never {
throw new Error("missing workspace adapter context")
}
function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter {
return {
name: "Remote Test",

View file

@ -4,8 +4,9 @@ import { tool, type ModelMessage } from "ai"
import { Cause, Effect, Exit, Stream } from "effect"
import z from "zod"
import { makeRuntime } from "../../src/effect/run-service"
import { InstanceRef } from "../../src/effect/instance-ref"
import { LLM } from "../../src/session/llm"
import { Instance } from "../../src/project/instance"
import type { InstanceContext } from "../../src/project/instance-context"
import { WithInstance } from "../../src/project/with-instance"
import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
@ -18,19 +19,21 @@ import { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
async function getModel(providerID: ProviderID, modelID: ModelID) {
return AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.getModel(providerID, modelID)
}),
)
async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) {
const effect = Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.getModel(providerID, modelID)
})
return AppRuntime.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
}
const llm = makeRuntime(LLM.Service, LLM.defaultLayer)
async function drain(input: LLM.StreamInput) {
return llm.runPromise((svc) => svc.stream(input).pipe(Stream.runDrain))
async function drain(input: LLM.StreamInput, ctx: InstanceContext) {
return llm.runPromise((svc) => {
const effect = svc.stream(input).pipe(Stream.runDrain)
return effect.pipe(Effect.provideService(InstanceRef, ctx))
})
}
describe("session.llm.hasToolCalls", () => {
@ -360,8 +363,8 @@ describe("session.llm.stream", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
fn: async (ctx) => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-1")
const agent = {
name: "test",
@ -381,15 +384,18 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make(providerID), modelID: resolved.id, variant: "high" },
} satisfies MessageV2.User
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
await drain(
{
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
},
ctx,
)
const capture = await request
const body = capture.body
@ -447,8 +453,8 @@ describe("session.llm.stream", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
fn: async (ctx) => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-service-abort")
const agent = {
name: "test",
@ -478,7 +484,7 @@ describe("session.llm.stream", () => {
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
.pipe(Stream.runDrain),
.pipe(Stream.runDrain, Effect.provideService(InstanceRef, ctx)),
{ signal: ctrl.signal },
)
@ -537,8 +543,8 @@ describe("session.llm.stream", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
fn: async (ctx) => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-tools")
const agent = {
name: "test",
@ -557,22 +563,25 @@ describe("session.llm.stream", () => {
tools: { question: true },
} satisfies MessageV2.User
await drain({
user,
sessionID,
model: resolved,
agent,
permission: [{ permission: "question", pattern: "*", action: "allow" }],
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {
question: tool({
description: "Ask a question",
inputSchema: z.object({}),
execute: async () => ({ output: "" }),
}),
await drain(
{
user,
sessionID,
model: resolved,
agent,
permission: [{ permission: "question", pattern: "*", action: "allow" }],
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {
question: tool({
description: "Ask a question",
inputSchema: z.object({}),
execute: async () => ({ output: "" }),
}),
},
},
})
ctx,
)
const capture = await request
const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined
@ -651,8 +660,8 @@ describe("session.llm.stream", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id))
fn: async (ctx) => {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-2")
const agent = {
name: "test",
@ -671,15 +680,18 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
} satisfies MessageV2.User
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
await drain(
{
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
},
ctx,
)
const capture = await request
const body = capture.body
@ -767,8 +779,8 @@ describe("session.llm.stream", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id))
fn: async (ctx) => {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-data-url")
const agent = {
name: "test",
@ -786,28 +798,31 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
} satisfies MessageV2.User
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [
{
role: "user",
content: [
{ type: "text", text: "Describe this image" },
{
type: "file",
mediaType: "image/png",
filename: "large-image.png",
data: image,
},
],
},
] as ModelMessage[],
tools: {},
})
await drain(
{
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [
{
role: "user",
content: [
{ type: "text", text: "Describe this image" },
{
type: "file",
mediaType: "image/png",
filename: "large-image.png",
data: image,
},
],
},
] as ModelMessage[],
tools: {},
},
ctx,
)
const capture = await request
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
@ -886,8 +901,8 @@ describe("session.llm.stream", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
fn: async (ctx) => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-3")
const agent = {
name: "test",
@ -907,15 +922,18 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.5") },
} satisfies MessageV2.User
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
await drain(
{
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
},
ctx,
)
const capture = await request
const body = capture.body
@ -1004,8 +1022,8 @@ describe("session.llm.stream", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id))
fn: async (ctx) => {
const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-anthropic-tools")
const agent = {
name: "test",
@ -1110,31 +1128,34 @@ describe("session.llm.stream", () => {
},
] as any[]
await drain({
user,
sessionID,
model: resolved,
agent,
system: [],
messages: await MessageV2.toModelMessages(input as any, resolved),
tools: {
read: tool({
description: "Stub read tool",
inputSchema: z.object({
filePath: z.string(),
await drain(
{
user,
sessionID,
model: resolved,
agent,
system: [],
messages: await MessageV2.toModelMessages(input as any, resolved),
tools: {
read: tool({
description: "Stub read tool",
inputSchema: z.object({
filePath: z.string(),
}),
execute: async () => ({ output: "stub" }),
}),
execute: async () => ({ output: "stub" }),
}),
glob: tool({
description: "Stub glob tool",
inputSchema: z.object({
pattern: z.string(),
path: z.string().optional(),
glob: tool({
description: "Stub glob tool",
inputSchema: z.object({
pattern: z.string(),
path: z.string().optional(),
}),
execute: async () => ({ output: "stub" }),
}),
execute: async () => ({ output: "stub" }),
}),
},
},
})
ctx,
)
const capture = await request
const body = capture.body
@ -1245,8 +1266,8 @@ describe("session.llm.stream", () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
fn: async (ctx) => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id), ctx)
const sessionID = SessionID.make("session-test-4")
const agent = {
name: "test",
@ -1266,15 +1287,18 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
} satisfies MessageV2.User
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
await drain(
{
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {},
},
ctx,
)
const capture = await request
const body = capture.body

View file

@ -48,7 +48,7 @@ describe("tool.assertExternalDirectory", () => {
}),
)
it.live("no-ops for paths inside Instance.directory", () =>
it.live("no-ops for paths inside the instance directory", () =>
provideInstance("/tmp/project")(
Effect.gen(function* () {
const { requests, ctx } = makeCtx()

View file

@ -6,7 +6,6 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { LSP } from "@/lsp/lsp"
import { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance"
import { MessageID, SessionID } from "../../src/session/schema"
import { Tool } from "@/tool/tool"
import { Truncate } from "@/tool/truncate"

View file

@ -10,7 +10,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { Git } from "@/git"
import { LSP } from "@/lsp/lsp"
import { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance"
import { SessionID, MessageID } from "../../src/session/schema"
import { Instruction } from "../../src/session/instruction"
import { ReadTool } from "../../src/tool/read"

View file

@ -5,7 +5,6 @@ import path from "path"
import { pathToFileURL } from "url"
import type { Permission } from "../../src/permission"
import type { Tool } from "@/tool/tool"
import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill"
import { ToolRegistry } from "@/tool/registry"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"

View file

@ -3,7 +3,6 @@ import { Effect, Layer } from "effect"
import path from "path"
import fs from "fs/promises"
import { WriteTool } from "../../src/tool/write"
import { Instance } from "../../src/project/instance"
import { LSP } from "@/lsp/lsp"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Bus } from "../../src/bus"