feat(core): add location-scoped config loading (#29625)

This commit is contained in:
Dax
2026-05-30 00:06:08 -04:00
committed by GitHub
parent 5fb85a6aa3
commit 9583e08be4
89 changed files with 3507 additions and 525 deletions

View File

@@ -19,12 +19,11 @@ const it = testEffect(PluginV2.defaultLayer)
function context(
records: { provider: ProviderV2.Info; models: Map<ModelV2.ID, ModelV2.Info> }[],
updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }>,
): Catalog.Context {
): Catalog.Editor {
return {
data: records,
updateProvider: (providerID, fn) => context(records, updates).provider.update(providerID, fn),
updateModel: (providerID, modelID, fn) => context(records, updates).model.update(providerID, modelID, fn),
provider: {
list: () => records,
get: (providerID) => records.find((item) => item.provider.id === providerID),
update: (providerID, fn) => {
const record = records.find((item) => item.provider.id === providerID)
const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn)
@@ -45,8 +44,13 @@ function context(
},
},
model: {
get: () => undefined,
update: () => {},
remove: () => {},
default: {
get: () => undefined,
set: () => {},
},
},
}
}
@@ -192,7 +196,7 @@ describe("AccountV2", () => {
]
const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = []
const catalog = Catalog.Service.of({
loader: () => Effect.die("unexpected catalog.loader"),
transform: () => Effect.die("unexpected catalog.transform"),
provider: {
get: () => Effect.die("unexpected provider.get"),
all: () => Effect.succeed([]),
@@ -203,7 +207,6 @@ describe("AccountV2", () => {
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(Option.none<ModelV2.Info>()),
setDefault: () => Effect.die("unexpected model.setDefault"),
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
},
})

View File

@@ -0,0 +1,105 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Scope } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { testEffect } from "./lib/effect"
const it = testEffect(AgentV2.defaultLayer)
describe("AgentV2", () => {
it.effect("starts without agents", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
expect(yield* agent.all()).toEqual([])
expect(yield* agent.get(AgentV2.ID.make("build"))).toBeUndefined()
}),
)
it.effect("materializes replayable agent transforms", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("reviewer")
const transform = yield* agent.transform()
yield* transform((editor) =>
editor.update(id, (info) => {
info.description = "Reviews code"
info.mode = "subagent"
}),
)
expect(yield* agent.get(id)).toMatchObject({ id, description: "Reviews code", mode: "subagent" })
expect((yield* agent.all()).map((info) => info.id)).toEqual([id])
}),
)
it.effect("rebuilds state when a transform is replaced", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("reviewer")
const transform = yield* agent.transform()
yield* transform((editor) =>
editor.update(id, (info) => {
info.description = "Old description"
info.hidden = true
}),
)
yield* transform((editor) =>
editor.update(id, (info) => {
info.description = "New description"
}),
)
expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false })
}),
)
it.effect("removes a transform contribution when its scope closes", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("scoped")
const scope = yield* Scope.make()
const transform = yield* agent.transform().pipe(Scope.provide(scope))
yield* transform((editor) => editor.update(id, () => {}))
expect(yield* agent.get(id)).toBeDefined()
yield* Scope.close(scope, Exit.void)
expect(yield* agent.get(id)).toBeUndefined()
}),
)
it.effect("applies direct agent updates", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("build")
yield* agent.update((editor) =>
Effect.sync(() =>
editor.update(id, (info) => {
info.mode = "primary"
info.hidden = true
}),
),
)
expect(yield* agent.get(id)).toMatchObject({ id, mode: "primary", hidden: true })
}),
)
it.effect("creates agents with runtime defaults and supports direct removal", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("custom")
yield* agent.update((editor) => Effect.sync(() => editor.update(id, () => {})))
expect(yield* agent.get(id)).toEqual(
AgentV2.Info.empty(id),
)
yield* agent.update((editor) => Effect.sync(() => editor.remove(id)))
expect(yield* agent.get(id)).toBeUndefined()
}),
)
})

View File

@@ -5,14 +5,21 @@ import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
const it = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(Policy.defaultLayer),
Layer.provideMerge(locationLayer),
),
)
@@ -22,9 +29,9 @@ describe("CatalogV2", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
yield* load((catalog) =>
yield* transform((catalog) =>
catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
@@ -48,9 +55,9 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
yield* load((catalog) => {
yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
@@ -77,9 +84,9 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
yield* load((catalog) => {
yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
@@ -104,14 +111,14 @@ describe("CatalogV2", () => {
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("test")
const seen: unknown[] = []
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
yield* plugin.add({
id: PluginV2.ID.make("test"),
effect: Effect.succeed({
"catalog.transform": (evt) =>
Effect.sync(() => {
const item = evt.data.find((record) => record.provider.id === providerID)
const item = evt.provider.get(providerID)
if (!item) return
seen.push(item.provider.endpoint.type)
if (item?.provider.endpoint.type === "aisdk") seen.push(item.provider.endpoint.url)
@@ -119,7 +126,7 @@ describe("CatalogV2", () => {
}),
}),
})
yield* load((catalog) =>
yield* transform((catalog) =>
catalog.provider.update(providerID, (provider) => {
provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
provider.options.aisdk.provider.baseURL = "https://provider.example.com"
@@ -135,9 +142,9 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
yield* load((catalog) =>
yield* transform((catalog) =>
catalog.provider.update(providerID, (provider) => {
provider.name = "Before"
}),
@@ -164,9 +171,9 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
yield* load((catalog) => {
yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.options.headers.provider = "provider"
provider.options.headers.shared = "provider"
@@ -194,9 +201,9 @@ describe("CatalogV2", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
yield* load((catalog) => {
yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
@@ -212,13 +219,44 @@ describe("CatalogV2", () => {
}),
)
it.effect("uses a transform-provided default model until that transform is replaced", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const old = ModelV2.ID.make("old")
const newest = ModelV2.ID.make("new")
const transform = yield* catalog.transform()
const models = (catalog: Catalog.Editor) => {
catalog.provider.update(providerID, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
catalog.model.update(providerID, old, (model) => {
model.time.released = DateTime.makeUnsafe(1000)
})
catalog.model.update(providerID, newest, (model) => {
model.time.released = DateTime.makeUnsafe(2000)
})
}
yield* transform((catalog) => {
models(catalog)
catalog.model.default.set(providerID, old)
})
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(old)
yield* transform(models)
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(newest)
}),
)
it.effect("small model prefers small keyword candidates before cost scoring", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
const transform = yield* catalog.transform()
yield* load((catalog) => {
yield* transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
model.capabilities.input = ["text"]
@@ -237,4 +275,23 @@ describe("CatalogV2", () => {
expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
}),
)
it.effect("removes providers denied by policy after loading", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const policy = yield* Policy.Service
const providerID = ProviderV2.ID.make("blocked")
const transform = yield* catalog.transform()
yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })])
yield* transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("model"), () => {})
})
expect(yield* catalog.provider.all()).toEqual([])
expect(yield* catalog.model.all()).toEqual([])
expect(yield* catalog.provider.get(providerID).pipe(Effect.option)).toEqual(Option.none())
}),
)
})

View File

@@ -0,0 +1,186 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { testEffect } from "../lib/effect"
const it = testEffect(AgentV2.defaultLayer)
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigAgentPlugin.Plugin", () => {
it.effect("applies global permissions between built-in and agent-specific permissions", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const build = AgentV2.ID.make("build")
const defaults = yield* agents.transform()
yield* defaults((editor) =>
editor.update(build, (agent) => {
agent.mode = "primary"
agent.permissions.push({ permission: "bash", pattern: "*", action: "allow" })
}),
)
const config = Config.Service.of({
directories: () => Effect.succeed([]),
get: () =>
Effect.succeed([
new Config.Loaded({
source: { type: "memory" },
info: decode({
permissions: [{ permission: "bash", pattern: "*", action: "ask" }],
agents: {
build: {
permissions: [{ permission: "bash", pattern: "git *", action: "allow" }],
},
reviewer: {
model: "openrouter/openai/gpt-5",
description: "Review changes",
mode: "subagent",
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
},
removed: { description: "Removed later" },
},
}),
}),
new Config.Loaded({
source: { type: "memory" },
info: decode({
agents: {
reviewer: { variant: "high", hidden: true },
removed: { disabled: true },
},
}),
}),
]),
})
yield* ConfigAgentPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(AgentV2.Service, agents),
)
const buildAgent = yield* agents.get(build)
if (!buildAgent) throw new Error("expected configured build agent")
expect(buildAgent.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "git *", action: "allow" },
])
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).action).toBe("allow")
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).action).toBe("ask")
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
if (!reviewer) throw new Error("expected configured reviewer agent")
expect(reviewer).toMatchObject({
description: "Review changes",
mode: "subagent",
hidden: true,
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
})
expect(reviewer.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "*", action: "deny" },
])
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
}),
)
it.effect("maps configured agent fields and preserves an unspecified model variant", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const config = Config.Service.of({
directories: () => Effect.succeed([]),
get: () =>
Effect.succeed([
new Config.Loaded({
source: { type: "memory" },
info: decode({
agents: {
reviewer: {
model: "anthropic/claude-sonnet",
system: "Review carefully.",
description: "Reviews changes",
mode: "subagent",
hidden: true,
color: "warning",
steps: 12,
options: {
headers: { first: "one", shared: "first" },
body: { enabled: true },
aisdk: { provider: { profile: "review" }, request: { effort: "medium" } },
},
},
},
}),
}),
new Config.Loaded({
source: { type: "memory" },
info: decode({
agents: {
reviewer: {
options: {
headers: { shared: "last", second: "two" },
body: { retries: 2 },
aisdk: { request: { effort: "high" } },
},
},
},
}),
}),
]),
})
yield* ConfigAgentPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(AgentV2.Service, agents),
)
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
if (!reviewer) throw new Error("expected configured reviewer agent")
expect(reviewer).toMatchObject({
system: "Review carefully.",
description: "Reviews changes",
mode: "subagent",
hidden: true,
color: "warning",
steps: 12,
model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined },
})
expect(reviewer.options).toEqual({
headers: { first: "one", shared: "last", second: "two" },
body: { enabled: true, retries: 2 },
aisdk: { provider: { profile: "review" }, request: { effort: "high" } },
})
}),
)
it.effect("removes a built-in agent disabled by configuration", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const build = AgentV2.ID.make("build")
const defaults = yield* agents.transform()
yield* defaults((editor) => editor.update(build, () => {}))
const config = Config.Service.of({
directories: () => Effect.succeed([]),
get: () =>
Effect.succeed([
new Config.Loaded({
source: { type: "memory" },
info: decode({ agents: { build: { disabled: true } } }),
}),
]),
})
yield* ConfigAgentPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(AgentV2.Service, agents),
)
expect(yield* agents.get(build)).toBeUndefined()
}),
)
})

View File

@@ -0,0 +1,451 @@
import path from "path"
import fs from "fs/promises"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigProvider } from "@opencode-ai/core/config/provider"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
import { Policy } from "@opencode-ai/core/policy"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.empty)
function testLayer(
directory: string,
globalDirectory = path.join(directory, "global"),
projectDirectory = directory,
vcs?: Project.Vcs,
) {
return Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.layerWith({ config: globalDirectory })),
Layer.provideMerge(Policy.defaultLayer),
Layer.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory), vcs },
),
),
),
),
)
}
const provider = {
endpoint: { type: "unknown" },
options: {
headers: {},
body: {},
aisdk: {
provider: {},
request: {},
},
},
models: {},
}
describe("Config", () => {
it.live("returns an empty configuration when directory files do not exist", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
expect(documents).toEqual([])
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
it.live("loads JSON and JSONC files from lowest to highest priority", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(
path.join(tmp.path, "config.json"),
JSON.stringify({ $schema: "base", providers: { base: provider } }),
),
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({ $schema: "middle", providers: { middle: provider } }),
),
fs.writeFile(
path.join(tmp.path, "opencode.jsonc"),
`{
// Later global files override scalar fields while retaining providers.
"$schema": "last",
"providers": { "last": ${JSON.stringify(provider)} },
}`,
),
]),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
expect(documents).toHaveLength(3)
expect(documents.map((document) => document.source.type)).toEqual(["file", "file", "file"])
expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
expect(documents[0]).toBeInstanceOf(Config.Loaded)
expect(documents[0]?.source.type === "file" ? documents[0].source.path : undefined).toBe(
path.join(tmp.path, "config.json"),
)
expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
yield* Effect.promise(() =>
fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
)
expect((yield* config.get()).map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
),
),
)
it.live("accepts $schema metadata without writing it into config files", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const file = path.join(tmp.path, "opencode.json")
const contents = JSON.stringify({
shell: "/bin/zsh",
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
providers: { local: provider },
})
yield* Effect.promise(() => fs.writeFile(file, contents))
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
expect(documents[0]?.info.$schema).toBeUndefined()
expect(documents[0]?.info.shell).toBe("/bin/zsh")
expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({
effect: "deny",
action: "provider.use",
resource: "openai",
})
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
),
),
)
it.live("loads supported scalar and resource configuration", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
shell: "/bin/bash",
model: "anthropic/claude",
autoupdate: "notify",
share: "disabled",
enterprise: { url: "https://share.example.com" },
username: "test-user",
permissions: [
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "git status", action: "allow" },
],
agents: {
reviewer: {
model: "openrouter/openai/gpt-5",
variant: "high",
options: {
headers: { "x-agent": "reviewer" },
aisdk: { request: { reasoningEffort: "high" } },
},
description: "Review changes for correctness",
system: "Find regressions.",
mode: "subagent",
hidden: false,
color: "warning",
steps: 12,
disabled: false,
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
},
},
snapshots: false,
watcher: { ignore: ["node_modules/**", "dist/**", ".git"] },
formatter: { prettier: { disabled: true }, custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] } },
lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
attachments: { image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 } },
tool_output: { max_lines: 1000, max_bytes: 32768 },
mcp: {
timeout: 5000,
servers: {
local: {
type: "local",
command: ["node", "./mcp/server.js"],
environment: { API_KEY: "secret" },
disabled: false,
timeout: 10000,
},
remote: {
type: "remote",
url: "https://mcp.example.com/mcp",
headers: { Authorization: "Bearer token" },
oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
disabled: true,
},
},
},
compaction: {
auto: true,
prune: false,
keep: { turns: 3, tokens: 2000 },
buffer: 10000,
},
skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"],
references: {
local: { path: "../library" },
sdk: { repository: "github.com/example/sdk", branch: "main" },
shorthand: "github.com/example/docs",
},
plugins: [
"opencode-helicone-session",
{ package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
],
}),
),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
expect(documents).toHaveLength(1)
expect(documents[0]?.info.shell).toBe("/bin/bash")
expect(documents[0]?.info.model).toBe("anthropic/claude")
expect(documents[0]?.info.autoupdate).toBe("notify")
expect(documents[0]?.info.share).toBe("disabled")
expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
expect(documents[0]?.info.username).toBe("test-user")
expect(documents[0]?.info.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "git status", action: "allow" },
])
expect(documents[0]?.info.agents?.reviewer).toEqual({
model: "openrouter/openai/gpt-5",
variant: "high",
options: {
headers: { "x-agent": "reviewer" },
aisdk: { request: { reasoningEffort: "high" } },
},
description: "Review changes for correctness",
system: "Find regressions.",
mode: "subagent",
hidden: false,
color: "warning",
steps: 12,
disabled: false,
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
})
expect(documents[0]?.info.snapshots).toBe(false)
expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
expect(documents[0]?.info.formatter).toEqual({
prettier: { disabled: true },
custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
})
expect(documents[0]?.info.lsp).toEqual({
typescript: { disabled: true },
custom: { command: ["custom-lsp"], extensions: [".foo"] },
})
expect(documents[0]?.info.attachments).toEqual({
image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
})
expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
expect(documents[0]?.info.mcp).toEqual({
timeout: 5000,
servers: {
local: {
type: "local",
command: ["node", "./mcp/server.js"],
environment: { API_KEY: "secret" },
disabled: false,
timeout: 10000,
},
remote: {
type: "remote",
url: "https://mcp.example.com/mcp",
headers: { Authorization: "Bearer token" },
oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
disabled: true,
},
},
})
expect(documents[0]?.info.compaction).toEqual({
auto: true,
prune: false,
keep: { turns: 3, tokens: 2000 },
buffer: 10000,
})
expect(documents[0]?.info.skills).toEqual([
"./skills",
"~/shared-skills",
"https://example.com/.well-known/skills/",
])
expect(documents[0]?.info.instructions).toEqual([
"CONTRIBUTING.md",
".cursor/rules/*.md",
"https://example.com/shared-rules.md",
])
expect(documents[0]?.info.references).toEqual({
local: { path: "../library" },
sdk: { repository: "github.com/example/sdk", branch: "main" },
shorthand: "github.com/example/docs",
})
expect(documents[0]?.info.plugins).toEqual([
"opencode-helicone-session",
{ package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
])
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
),
),
)
it.live("ignores invalid files while loading valid config values", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })),
fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"),
fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })),
]),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
),
),
)
it.live("loads policy statements in reverse config order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.writeFile(
path.join(global, "opencode.json"),
JSON.stringify({ experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] } }),
)
await fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({ experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] } }),
)
})
return yield* Effect.gen(function* () {
const policy = yield* Policy.Service
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}).pipe(Effect.provide(testLayer(tmp.path, global)))
})
}),
),
)
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const root = path.join(tmp.path, "repo")
const parent = path.join(root, "packages")
const directory = path.join(parent, "app")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(directory, { recursive: true })
await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
await Promise.all([
fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })),
fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
fs.writeFile(
path.join(directory, ".opencode", "opencode.jsonc"),
JSON.stringify({ $schema: "directory-dot" }),
),
])
})
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const directories = yield* config.directories()
const documents = yield* config.get()
expect(directories).toEqual([
AbsolutePath.make(global),
AbsolutePath.make(path.join(root, ".opencode")),
AbsolutePath.make(path.join(directory, ".opencode")),
])
expect(documents.map((document) => document.info.$schema)).toEqual([
"global",
"root",
"parent",
"directory",
"root-dot",
"directory-dot",
])
}).pipe(
Effect.provide(
testLayer(directory, global, root, {
type: "git",
store: AbsolutePath.make(path.join(root, ".git")),
}),
),
)
})
}),
),
)
})

View File

@@ -0,0 +1,131 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it } from "../plugin/provider-helper"
function options(headers: Record<string, string>, variant?: string) {
return {
headers,
variant,
}
}
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("loads configured providers and applies later model overrides", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat")
const config = Config.Service.of({
directories: () => Effect.succeed([]),
get: () =>
Effect.succeed([
new Config.Loaded({
source: { type: "memory" },
info: decode({
providers: {
custom: {
name: "Configured",
env: ["CUSTOM_API_KEY"],
endpoint: { type: "unknown" },
options: options({ first: "first", shared: "first" }),
models: {
chat: {
name: "First",
capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true,
limit: { context: 100, output: 50 },
cost: { input: 1, output: 2 },
options: options({ first: "first", shared: "first" }, "retained"),
variants: [
{
id: "fast",
headers: { first: "first", shared: "first" },
},
],
},
},
},
},
}),
}),
new Config.Loaded({
source: { type: "memory" },
info: decode({
providers: {
custom: {
endpoint: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
options: options({ last: "last", shared: "last" }),
models: {
chat: {
api_id: "api-chat",
name: "Last",
limit: { output: 75 },
options: options({ last: "last", shared: "last" }),
variants: [
{
id: "fast",
headers: { last: "last", shared: "last" },
},
{
id: "slow",
headers: { slow: "slow" },
},
],
},
},
},
},
}),
}),
new Config.Loaded({
source: { type: "memory" },
info: decode({
providers: {
custom: { name: "Renamed" },
},
}),
}),
]),
})
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
),
})
const provider = yield* catalog.provider.get(providerID)
const model = yield* catalog.model.get(providerID, modelID)
expect(provider.name).toBe("Renamed")
expect(provider.env).toEqual(["CUSTOM_API_KEY"])
expect(provider.enabled).toEqual({ via: "custom", data: {} })
expect(provider.endpoint).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
expect(provider.options.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.apiID).toBe(ModelV2.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
expect(model.options.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.options.variant).toBe("retained")
expect(model.variants.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("fast"),
ModelV2.VariantID.make("slow"),
])
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
}),
)
})

View File

@@ -2,11 +2,13 @@ import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of({ directory: "project", workspaceID: "workspace" }),
Location.Service.of(location({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })),
)
const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer)))
const itWithoutLocation = testEffect(EventV2.layer)
@@ -46,7 +48,7 @@ describe("EventV2", () => {
expect(event.type).toBe("test.message")
expect(event).not.toHaveProperty("version")
expect(event.data).toEqual({ text: "hello" })
expect(event.location).toEqual({ directory: "project", workspaceID: "workspace" })
expect(event.location).toEqual({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })
}),
)

View File

@@ -0,0 +1,12 @@
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return {
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
vcs: input.vcs,
} satisfies Location.Interface
}

View File

@@ -0,0 +1,38 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "./lib/effect"
const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID: "workspace" }
const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),
directory: AbsolutePath.make("/repo"),
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
}),
commit: () => Effect.void,
}),
)
const it = testEffect(Location.layer(ref).pipe(Layer.provide(projectLayer)))
describe("Location", () => {
it.effect("resolves the current project and vcs information", () =>
Effect.gen(function* () {
const location = yield* Location.Service
expect(location.directory).toBe(AbsolutePath.make("/repo/packages/app"))
expect(location.workspaceID).toBe("workspace")
expect(location.project.id).toBe(Project.ID.make("project"))
expect(location.project.directory).toBe(AbsolutePath.make("/repo"))
expect(location.vcs).toEqual({
type: "git",
store: AbsolutePath.make("/repo/.git"),
})
}),
)
})

View File

@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
const decode = Schema.decodeUnknownSync(ModelV2.Ref)
describe("ModelV2.Ref", () => {
test("accepts a model selection without a variant", () => {
expect(decode({ id: "claude-sonnet", providerID: "anthropic" })).toEqual({
id: ModelV2.ID.make("claude-sonnet"),
providerID: ProviderV2.ID.make("anthropic"),
})
})
test("preserves an explicit model variant", () => {
expect(decode({ id: "claude-sonnet", providerID: "anthropic", variant: "high" })).toEqual({
id: ModelV2.ID.make("claude-sonnet"),
providerID: ProviderV2.ID.make("anthropic"),
variant: ModelV2.VariantID.make("high"),
})
})
})

View File

@@ -24,8 +24,8 @@ describe("AmazonBedrockPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AmazonBedrockPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const bedrock = provider("amazon-bedrock", {
endpoint: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
options: {

View File

@@ -12,8 +12,8 @@ describe("AnthropicPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AnthropicPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("anthropic", {
endpoint: { type: "aisdk", package: "@ai-sdk/anthropic" },
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
@@ -35,8 +35,8 @@ describe("AnthropicPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AnthropicPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(provider("openai").id, () => {}))
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(provider("openai").id, () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.headers["anthropic-beta"]).toBeUndefined()
}),
)

View File

@@ -13,8 +13,8 @@ describe("AzureCognitiveServicesPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzureCognitiveServicesPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
item.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
})
@@ -37,8 +37,8 @@ describe("AzureCognitiveServicesPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzureCognitiveServicesPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const azure = provider("azure-cognitive-services", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
})

View File

@@ -5,9 +5,12 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
@@ -16,7 +19,10 @@ const itWithAccount = testEffect(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provide(Policy.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
Layer.provideMerge(npmLayer),
),
)
@@ -28,8 +34,8 @@ describe("AzurePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" }
})
@@ -45,8 +51,8 @@ describe("AzurePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const azure = provider("azure", {
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } },
@@ -94,8 +100,8 @@ describe("AzurePlugin", () => {
),
})
yield* plugin.add(AzurePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" }
})
@@ -113,8 +119,8 @@ describe("AzurePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const azure = provider("azure", {
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } },
@@ -135,8 +141,8 @@ describe("AzurePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const azure = provider("azure", {
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } },

View File

@@ -24,8 +24,8 @@ describe("CerebrasPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CerebrasPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
item.endpoint = { type: "aisdk", package: "@ai-sdk/cerebras" }
item.options.headers.Existing = "1"
@@ -43,8 +43,8 @@ describe("CerebrasPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CerebrasPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).options.headers).toEqual({})
}),
)

View File

@@ -6,9 +6,12 @@ import { Location } from "@opencode-ai/core/location"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper"
@@ -17,7 +20,10 @@ const itWithAccount = testEffect(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provide(Policy.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
Layer.provideMerge(npmLayer),
),
)
@@ -48,8 +54,8 @@ describe("CloudflareWorkersAIPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CloudflareWorkersAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.endpoint = { type: "aisdk", package: "test-provider" }
}),
@@ -80,8 +86,8 @@ describe("CloudflareWorkersAIPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CloudflareWorkersAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.endpoint = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
}),
@@ -146,8 +152,8 @@ describe("CloudflareWorkersAIPlugin", () => {
),
})
yield* plugin.add(CloudflareWorkersAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.endpoint = { type: "aisdk", package: "test-provider" }
}),
@@ -167,8 +173,8 @@ describe("CloudflareWorkersAIPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CloudflareWorkersAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.endpoint = { type: "aisdk", package: "test-provider" }
provider.options.aisdk.provider.accountId = "configured-acct"

View File

@@ -152,8 +152,8 @@ describe("GithubCopilotPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GithubCopilotPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {})
catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})
@@ -168,8 +168,8 @@ describe("GithubCopilotPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GithubCopilotPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {})
catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})

View File

@@ -5,9 +5,11 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "../lib/effect"
import { it, model, npmLayer, withEnv } from "./provider-helper"
@@ -27,12 +29,15 @@ void mock.module("gitlab-ai-provider", () => ({
}))
const itWithAccount = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provideMerge(npmLayer),
Layer.mergeAll(
Catalog.defaultLayer,
PluginV2.defaultLayer,
AccountV2.defaultLayer,
EventV2.defaultLayer,
npmLayer,
).pipe(
Layer.provide(Policy.defaultLayer),
Layer.provide(Location.defaultLayer({ directory: AbsolutePath.make("/") })),
),
)
@@ -179,8 +184,8 @@ describe("GitLabPlugin", () => {
),
})
yield* plugin.add(GitLabPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
yield* plugin.trigger(
"aisdk.sdk",
@@ -227,8 +232,8 @@ describe("GitLabPlugin", () => {
),
})
yield* plugin.add(GitLabPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
yield* plugin.trigger(
"aisdk.sdk",

View File

@@ -22,8 +22,8 @@ describe("GoogleVertexAnthropicPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexAnthropicPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
}),
@@ -41,8 +41,8 @@ describe("GoogleVertexAnthropicPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexAnthropicPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
provider.options.aisdk.provider.project = "configured-project"

View File

@@ -50,8 +50,8 @@ describe("GoogleVertexPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.endpoint = {
type: "aisdk",
@@ -89,8 +89,8 @@ describe("GoogleVertexPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.endpoint = {
type: "aisdk",
@@ -139,8 +139,8 @@ describe("GoogleVertexPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.endpoint = {
type: "aisdk",
@@ -168,8 +168,8 @@ describe("GoogleVertexPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.endpoint = {
type: "aisdk",
@@ -204,8 +204,8 @@ describe("GoogleVertexPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex" }
provider.options.aisdk.provider.project = "config-project"

View File

@@ -7,11 +7,17 @@ import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
export const npmLayer = Layer.succeed(
Npm.Service,
@@ -25,7 +31,7 @@ export const npmLayer = Layer.succeed(
export const catalogLayer = Layer.succeed(
Catalog.Service,
Catalog.Service.of({
loader: () => Effect.die("unexpected catalog.loader"),
transform: () => Effect.die("unexpected catalog.transform"),
provider: {
get: () => Effect.die("unexpected provider.get"),
all: () => Effect.succeed([]),
@@ -36,7 +42,6 @@ export const catalogLayer = Layer.succeed(
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(Option.none<ModelV2.Info>()),
setDefault: () => Effect.die("unexpected model.setDefault"),
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
},
}),
@@ -46,6 +51,7 @@ export const it = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provide(Policy.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(npmLayer),
),

View File

@@ -22,8 +22,8 @@ describe("KiloPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(KiloPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const kilo = provider("kilo", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
@@ -48,8 +48,8 @@ describe("KiloPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(KiloPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("kilo", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
})
@@ -74,8 +74,8 @@ describe("KiloPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(KiloPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const kilo = provider("kilo", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
})

View File

@@ -22,8 +22,8 @@ describe("LLMGatewayPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const llmgateway = provider("llmgateway", {
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
@@ -56,8 +56,8 @@ describe("LLMGatewayPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("llmgateway", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
})

View File

@@ -22,8 +22,8 @@ describe("NvidiaPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(NvidiaPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const nvidia = provider("nvidia", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
@@ -49,8 +49,8 @@ describe("NvidiaPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(NvidiaPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("nvidia", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
@@ -74,8 +74,8 @@ describe("NvidiaPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(NvidiaPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("nvidia", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
options: {

View File

@@ -77,8 +77,8 @@ describe("OpenAIPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("openai", { endpoint: { type: "aisdk", package: "@ai-sdk/openai" } })
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
@@ -96,8 +96,8 @@ describe("OpenAIPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("custom-openai")
catalog.provider.update(item.id, () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})

View File

@@ -5,11 +5,17 @@ import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { Policy } from "@opencode-ai/core/policy"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { it, model, provider, withEnv } from "./provider-helper"
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
describe("OpencodePlugin", () => {
it.effect("uses a public key and disables paid models without credentials", () =>
@@ -18,8 +24,8 @@ describe("OpencodePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const paid = model("opencode", "paid", { cost: cost(1) })
@@ -39,8 +45,8 @@ describe("OpencodePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const free = model("opencode", "free", { cost: cost(0) })
@@ -60,8 +66,8 @@ describe("OpencodePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) })
@@ -81,8 +87,8 @@ describe("OpencodePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const paid = model("opencode", "paid", { cost: cost(1) })
@@ -102,8 +108,8 @@ describe("OpencodePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] })
catalog.provider.update(item.id, (draft) => {
draft.env = [...item.env]
@@ -125,8 +131,8 @@ describe("OpencodePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", {
options: {
headers: {},
@@ -157,8 +163,8 @@ describe("OpencodePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", { enabled: { via: "account", service: "opencode" } })
catalog.provider.update(item.id, (draft) => {
draft.enabled = item.enabled
@@ -180,8 +186,8 @@ describe("OpencodePlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("openai")
catalog.provider.update(item.id, () => {})
const paid = model("openai", "paid", { cost: cost(1) })
@@ -200,8 +206,8 @@ describe("OpencodePlugin", () => {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.opencode
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
model.capabilities.input = ["text"]
@@ -220,6 +226,8 @@ describe("OpencodePlugin", () => {
const selected = yield* catalog.model.small(providerID)
expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(locationLayer)))),
}).pipe(
Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(Policy.defaultLayer), Layer.provide(locationLayer))),
),
)
})

View File

@@ -23,8 +23,8 @@ describe("OpenRouterPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenRouterPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const openrouter = provider("openrouter", {
endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
@@ -75,8 +75,8 @@ describe("OpenRouterPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenRouterPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const openrouter = provider("openrouter", {
endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
})
@@ -108,8 +108,8 @@ describe("OpenRouterPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenRouterPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {})
catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})

View File

@@ -12,8 +12,8 @@ describe("VercelPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(VercelPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("vercel", {
endpoint: { type: "aisdk", package: "@ai-sdk/vercel" },
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
@@ -36,8 +36,8 @@ describe("VercelPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(VercelPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("vercel", { endpoint: { type: "aisdk", package: "@ai-sdk/vercel" } })
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
@@ -69,8 +69,8 @@ describe("VercelPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(VercelPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(provider("gateway").id, () => {}))
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(provider("gateway").id, () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).options.headers).toEqual({})
}),
)

View File

@@ -22,8 +22,8 @@ describe("ZenmuxPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("zenmux", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
})
@@ -42,8 +42,8 @@ describe("ZenmuxPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("zenmux", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
@@ -67,8 +67,8 @@ describe("ZenmuxPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("zenmux", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
options: {
@@ -95,8 +95,8 @@ describe("ZenmuxPlugin", () => {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("openrouter", {
options: {
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },

View File

@@ -0,0 +1,83 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Location } from "@opencode-ai/core/location"
import { Policy } from "@opencode-ai/core/policy"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const it = testEffect(
Policy.defaultLayer.pipe(
Layer.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
),
)
describe("Policy", () => {
it.effect("returns the caller's fallback when no statement matches", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
expect(yield* policy.evaluate("provider.use", "anthropic", "deny")).toBe("deny")
}),
)
it.effect("evaluates wildcard provider rules in written order", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "deny",
action: "provider.*",
resource: "*",
}),
new Policy.Info({
effect: "allow",
action: "provider.use",
resource: "anthropic",
}),
])
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}),
)
it.effect("matches action and resource independently", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "deny",
action: "provider.*",
resource: "company-*",
}),
])
expect(yield* policy.evaluate("provider.use", "company-stable", "allow")).toBe("deny")
expect(yield* policy.evaluate("plugin.load", "company-stable", "allow")).toBe("allow")
}),
)
it.effect("uses the last matching loaded statement", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "allow",
action: "provider.use",
resource: "openai",
}),
new Policy.Info({
effect: "deny",
action: "provider.use",
resource: "openai",
}),
])
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}),
)
})

View File

@@ -49,7 +49,7 @@ describe("ProjectV2.resolve", () => {
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make("global"))
expect(path.resolve(result.directory)).toBe(path.resolve(tmp.path))
expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
expect(result.previous).toBeUndefined()
expect(result.vcs).toBeUndefined()
}),