refactor(core): consolidate tool architecture

This commit is contained in:
Dax Raad 2026-07-26 20:08:55 -04:00
commit 8db7487c89
466 changed files with 9405 additions and 11071 deletions

View file

@ -1,12 +1,12 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { AgentV2 } from "@opencode-ai/core/agent"
import { EventV2 } from "@opencode-ai/core/event"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Permission } from "@opencode-ai/core/permission"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
@ -17,22 +17,22 @@ const testLocation = location({ directory: AbsolutePath.make("/project") })
const locationLayer = Layer.succeed(Location.Service, Location.Service.of(testLocation))
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([AgentV2.node, EventV2.node, Location.node]), [
AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, Location.node]), [
[Location.node, locationLayer],
]) as unknown as Layer.Layer<unknown, never>,
)
describe("AgentV2", () => {
describe("Agent", () => {
it.effect("publishes an updated event after agent changes", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const events = yield* EventV2.Service
const updated = yield* events
.subscribe(AgentV2.Event.Updated)
const agent = yield* Agent.Service
const bus = yield* Bus.Service
const updated = yield* bus
.subscribe(Agent.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* agent.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), () => {}))
yield* agent.transform((editor) => editor.update(Agent.ID.make("reviewer"), () => {}))
expect(yield* Fiber.join(updated)).toMatchObject([{ location: { directory: testLocation.directory } }])
}),
@ -40,17 +40,17 @@ describe("AgentV2", () => {
it.effect("starts without agents", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const agent = yield* Agent.Service
expect(yield* agent.list()).toEqual([])
expect(yield* agent.get(AgentV2.ID.make("build"))).toBeUndefined()
expect(yield* agent.get(Agent.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 agent = yield* Agent.Service
const id = Agent.ID.make("reviewer")
yield* agent.transform((editor) =>
editor.update(id, (info) => {
info.description = "Reviews code"
@ -65,8 +65,8 @@ describe("AgentV2", () => {
it.effect("rebuilds state when a transform is replaced", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("reviewer")
const agent = yield* Agent.Service
const id = Agent.ID.make("reviewer")
let description = "Old description"
let hidden = true
yield* agent.transform((editor) =>
@ -87,8 +87,8 @@ describe("AgentV2", () => {
it.effect("removes a transform when its scope closes", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("scoped")
const agent = yield* Agent.Service
const id = Agent.ID.make("scoped")
const scope = yield* Scope.make()
yield* agent.transform((editor) => editor.update(id, () => {})).pipe(Scope.provide(scope))
expect(yield* agent.get(id)).toBeDefined()
@ -100,8 +100,8 @@ describe("AgentV2", () => {
it.effect("applies direct agent updates", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("build")
const agent = yield* Agent.Service
const id = Agent.ID.make("build")
yield* agent.transform((editor) =>
editor.update(id, (info) => {
@ -116,11 +116,11 @@ describe("AgentV2", () => {
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")
const agent = yield* Agent.Service
const id = Agent.ID.make("custom")
yield* agent.transform((editor) => editor.update(id, () => {}))
expect(yield* agent.get(id)).toEqual(AgentV2.Info.empty(id))
expect(yield* agent.get(id)).toEqual(Agent.Info.empty(id))
yield* agent.transform((editor) => editor.remove(id))
expect(yield* agent.get(id)).toBeUndefined()
@ -129,7 +129,7 @@ describe("AgentV2", () => {
it.effect("does not ambiently opt built-in agents into bash", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const agent = yield* Agent.Service
yield* AgentPlugin.Plugin.effect(
host({
agent: agentHost(agent),
@ -151,7 +151,7 @@ describe("AgentV2", () => {
"summary",
"title",
])
expect((yield* agent.get(AgentV2.defaultID))?.system).toBeUndefined()
expect((yield* agent.get(Agent.defaultID))?.system).toBeUndefined()
for (const item of agents) {
expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false)
}
@ -160,7 +160,7 @@ describe("AgentV2", () => {
it.effect("denies the subagent tool for built-in subagents", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const agent = yield* Agent.Service
yield* AgentPlugin.Plugin.effect(
host({
agent: agentHost(agent),
@ -174,11 +174,11 @@ describe("AgentV2", () => {
yield* Effect.forEach(["general", "explore"], (id) =>
Effect.gen(function* () {
const info = yield* agent.get(AgentV2.ID.make(id))
const info = yield* agent.get(Agent.ID.make(id))
if (!info) throw new Error(`expected built-in agent: ${id}`)
expect(info.mode).toBe("subagent")
expect(info.permissions).toContainEqual({ action: "subagent", resource: "*", effect: "deny" })
expect(PermissionV2.evaluate("subagent", "*", info.permissions).effect).toBe("deny")
expect(Permission.evaluate("subagent", "*", info.permissions).effect).toBe("deny")
}),
)
}),

View file

@ -1,7 +1,7 @@
import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider"
import { AISDK } from "@opencode-ai/core/aisdk"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { LLM, LLMError, LLMEvent, Message } from "@opencode-ai/ai"
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
import { expect } from "bun:test"
@ -11,10 +11,10 @@ import { testEffect } from "./lib/effect"
const it = testEffect(AISDK.locationLayer)
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("catalog-model")),
modelID: ModelV2.ID.make("api-model"),
package: ProviderV2.aisdk(packageName),
Model.Info.make({
...Model.Info.default(Provider.ID.make("test-provider"), Model.ID.make("catalog-model")),
modelID: Model.ID.make("api-model"),
package: Provider.aisdk(packageName),
settings,
limit: { context: 100, output: 20 },
})
@ -185,7 +185,7 @@ it.effect("routes AI Gateway model options by upstream prefix", () =>
gateway: { order: ["anthropic"] },
thinking: { type: "adaptive" },
}),
modelID: ModelV2.ID.make("anthropic/claude-sonnet-5"),
modelID: Model.ID.make("anthropic/claude-sonnet-5"),
})
const anthropicPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: anthropic, prompt: "Hello" }),
@ -197,7 +197,7 @@ it.effect("routes AI Gateway model options by upstream prefix", () =>
const bedrock = yield* aisdk.model({
...model("@ai-sdk/gateway", { reasoningConfig: { type: "enabled" } }),
modelID: ModelV2.ID.make("amazon/nova-2-lite"),
modelID: Model.ID.make("amazon/nova-2-lite"),
})
const bedrockPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: bedrock, prompt: "Hello" }),
@ -208,7 +208,7 @@ it.effect("routes AI Gateway model options by upstream prefix", () =>
const fallback = yield* aisdk.model({
...model("@ai-sdk/gateway", { reasoningEffort: "high" }),
modelID: ModelV2.ID.make("deepseek/deepseek-v4"),
modelID: Model.ID.make("deepseek/deepseek-v4"),
})
const fallbackPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: fallback, prompt: "Hello" }),

View file

@ -7,10 +7,10 @@ import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
@ -25,22 +25,22 @@ const locationLayer = Layer.succeed(
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
const catalogLayer = AppNodeBuilder.build(
LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node]),
LayerNode.group([Catalog.node, Bus.node, Credential.node, Integration.node]),
[[Location.node, locationLayer]],
)
const it = testEffect(catalogLayer)
describe("CatalogV2", () => {
describe("Catalog", () => {
it.effect("publishes an updated event after catalog changes", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
const updated = yield* events
const bus = yield* Bus.Service
const updated = yield* bus
.subscribe(Catalog.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
yield* catalog.transform((editor) => editor.provider.update(Provider.ID.make("test"), () => {}))
expect((yield* Fiber.join(updated)).length).toBe(1)
}),
@ -55,28 +55,28 @@ describe("CatalogV2", () => {
return Effect.gen(function* () {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
yield* catalog.transform((editor) => editor.provider.update(Provider.ID.make("test"), () => {}))
yield* credentials.create({
integrationID,
label: "First",
value: Credential.Key.make({ type: "key", key: "first", metadata: { tenant: "one" } }),
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).body).toBeUndefined()
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([Provider.ID.make("test")])
expect(required(yield* catalog.provider.get(Provider.ID.make("test"))).body).toBeUndefined()
yield* credentials.create({
integrationID,
label: "Second",
value: Credential.Key.make({ type: "key", key: "second", metadata: { tenant: "two" } }),
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).body).toBeUndefined()
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([Provider.ID.make("test")])
expect(required(yield* catalog.provider.get(Provider.ID.make("test"))).body).toBeUndefined()
}).pipe(Effect.provide(localCatalogLayer))
})
it.effect("derives availability from a provider's integration", () => {
const integrationID = Integration.ID.make("gateway")
const providerID = ProviderV2.ID.make("remote")
const providerID = Provider.ID.make("remote")
const localCatalogLayer = Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
[Location.node, locationLayer],
@ -113,7 +113,7 @@ describe("CatalogV2", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const providerID = ProviderV2.ID.make("test")
const providerID = Provider.ID.make("test")
yield* integrations.transform((editor) =>
editor.method.update({
integrationID: Integration.ID.make(providerID),
@ -135,16 +135,16 @@ describe("CatalogV2", () => {
it.effect("stores provider package settings", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const providerID = Provider.ID.make("test")
yield* catalog.transform((catalog) =>
catalog.provider.update(providerID, (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://override.example.com" }
}),
)
expect(required(yield* catalog.provider.get(providerID))).toMatchObject({
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://override.example.com" },
})
}),
@ -153,23 +153,23 @@ describe("CatalogV2", () => {
it.effect("uses model package settings over provider settings", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const providerID = Provider.ID.make("test")
const modelID = Model.ID.make("model")
yield* catalog.transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://provider.example.com" }
})
catalog.model.update(providerID, modelID, (model) => {
model.modelID = ModelV2.ID.make("upstream-model")
model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
model.modelID = Model.ID.make("upstream-model")
model.package = Provider.aisdk("@ai-sdk/openai-compatible")
model.settings = { baseURL: "https://override.example.com" }
})
})
expect(required(yield* catalog.model.get(providerID, modelID))).toMatchObject({
modelID: ModelV2.ID.make("upstream-model"),
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
modelID: Model.ID.make("upstream-model"),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://override.example.com" },
})
}),
@ -178,18 +178,18 @@ describe("CatalogV2", () => {
it.effect("resolves default model package settings from the provider", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const providerID = Provider.ID.make("test")
const modelID = Model.ID.make("model")
yield* catalog.transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://provider.example.com" }
})
catalog.model.update(providerID, modelID, () => {})
})
expect(required(yield* catalog.model.get(providerID, modelID))).toMatchObject({
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://provider.example.com" },
})
}),
@ -198,8 +198,8 @@ describe("CatalogV2", () => {
it.effect("resolves provider and model overlay merges", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const providerID = Provider.ID.make("test")
const modelID = Model.ID.make("model")
yield* catalog.transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.settings = { provider: true, shared: "provider" }
@ -223,13 +223,13 @@ describe("CatalogV2", () => {
it.effect("falls back to newest available model when no default is configured", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const providerID = Provider.ID.make("test")
yield* catalog.transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
catalog.model.update(providerID, Model.ID.make("old"), (model) => {
model.time.released = 1000
})
catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => {
catalog.model.update(providerID, Model.ID.make("new"), (model) => {
model.time.released = 2000
})
})
@ -241,9 +241,9 @@ 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 providerID = Provider.ID.make("test")
const old = Model.ID.make("old")
const newest = Model.ID.make("new")
const models = (catalog: Catalog.Draft) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, old, (model) => {
@ -272,10 +272,10 @@ describe("CatalogV2", () => {
it.effect("ignores a configured default on a disabled provider", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const disabledProvider = ProviderV2.ID.make("disabled")
const enabledProvider = ProviderV2.ID.make("enabled")
const disabledModel = ModelV2.ID.make("configured")
const fallbackModel = ModelV2.ID.make("fallback")
const disabledProvider = Provider.ID.make("disabled")
const enabledProvider = Provider.ID.make("enabled")
const disabledModel = Model.ID.make("configured")
const fallbackModel = Model.ID.make("fallback")
yield* catalog.transform((catalog) => {
catalog.provider.update(disabledProvider, (provider) => {
provider.disabled = true
@ -296,10 +296,10 @@ describe("CatalogV2", () => {
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 providerID = Provider.ID.make("test")
yield* catalog.transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
catalog.model.update(providerID, Model.ID.make("cheap-large"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [
@ -314,7 +314,7 @@ describe("CatalogV2", () => {
]
model.time.released = Date.now()
})
catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
catalog.model.update(providerID, Model.ID.make("expensive-mini"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [

View file

@ -1,34 +1,32 @@
import { describe, expect } from "bun:test"
import { CodeMode } from "@opencode-ai/core/codemode"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Tool } from "@opencode-ai/core/tool/tool"
import { Tool } from "@opencode-ai/core/tool"
import { Effect, Schema } from "effect"
import { it } from "./lib/effect"
describe("CodeMode", () => {
it.effect("owns registrations, execute, and catalog materialization", () =>
Effect.gen(function* () {
const codeMode = yield* CodeMode.Service
yield* codeMode.register(
Tool.registrationEntries({
echo: Tool.make({
const tools = yield* Tool.Service
yield* tools.transform((draft) =>
draft.add({
name: "echo",
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
execute: ({ text }) => Effect.succeed({ output: text }),
}),
}),
)
const materialized = yield* codeMode.materialize()
expect(materialized.tool).toBeDefined()
expect(materialized.catalog).toStrictEqual([
const snapshot = yield* tools.snapshot()
expect(snapshot.definitions.some((tool) => tool.name === "execute")).toBe(true)
expect(snapshot.codeModeCatalog).toStrictEqual([
{
path: "echo",
description: "Echo text",
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
},
])
}).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(CodeMode.node))),
}).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(Tool.node))),
)
})

View file

@ -1,9 +1,8 @@
import { describe, expect } from "bun:test"
import { CodeMode } from "@opencode-ai/core/codemode"
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Tool } from "@opencode-ai/core/tool/tool"
import { Tool } from "@opencode-ai/core/tool"
import { Effect, Schema } from "effect"
import { it } from "../lib/effect"
import { readInitial, readUpdate } from "../lib/instructions"
@ -66,32 +65,45 @@ describe("CodeModeInstructions", () => {
)
it.effect("stores a canonical sorted snapshot so registration order does not churn history", () => {
const alpha = Tool.make({
const alpha = ({
name: "alpha",
description: "Alpha tool",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed({ output: "alpha" }),
})
const zeta = Tool.make({
const zeta = ({
name: "zeta",
description: "Zeta tool",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed({ output: "zeta" }),
})
const layer = AppNodeBuilder.build(CodeMode.node)
const layer = AppNodeBuilder.build(Tool.node)
return Effect.gen(function* () {
const codeMode = yield* CodeMode.Service
const tools = yield* Tool.Service
const initialized = yield* Effect.scoped(
Effect.gen(function* () {
yield* codeMode.register(Tool.registrationEntries({ zeta, alpha }, { namespace: "tools" }))
return yield* readInitial(CodeModeInstructions.make((yield* codeMode.materialize()).catalog))
yield* tools.transform((draft) => {
draft.add({ ...zeta, options: { namespace: "tools" } })
draft.add({ ...alpha, options: { namespace: "tools" } })
})
return yield* readInitial(
CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog),
)
}),
)
const reordered = yield* Effect.scoped(
Effect.gen(function* () {
yield* codeMode.register(Tool.registrationEntries({ alpha, zeta }, { namespace: "tools" }))
return yield* readUpdate(CodeModeInstructions.make((yield* codeMode.materialize()).catalog), initialized)
yield* tools.transform((draft) => {
draft.add({ ...alpha, options: { namespace: "tools" } })
draft.add({ ...zeta, options: { namespace: "tools" } })
})
return yield* readUpdate(
CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog),
initialized,
)
}),
)

View file

@ -1,27 +1,27 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CommandV2 } from "@opencode-ai/core/command"
import { Command } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "./fixture/mcp"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(CommandV2.node, [
AppNodeBuilder.build(Command.node, [
[MCP.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
]),
)
describe("CommandV2", () => {
describe("Command", () => {
it.effect("applies command transforms and preserves later overrides", () =>
Effect.gen(function* () {
const command = yield* CommandV2.Service
const command = yield* Command.Service
yield* command.transform((editor) => {
editor.update("review", (command) => {
command.template = "First"
@ -30,34 +30,34 @@ describe("CommandV2", () => {
editor.update("review", (command) => {
command.template = "Second"
command.model = {
id: ModelV2.ID.make("claude"),
providerID: ProviderV2.ID.make("anthropic"),
variant: ModelV2.VariantID.make("high"),
id: Model.ID.make("claude"),
providerID: Provider.ID.make("anthropic"),
variant: Model.VariantID.make("high"),
}
})
})
expect(yield* command.get("review")).toEqual(
CommandV2.Info.make({
Command.Info.make({
name: "review",
template: "Second",
description: "Review code",
model: {
id: ModelV2.ID.make("claude"),
providerID: ProviderV2.ID.make("anthropic"),
variant: ModelV2.VariantID.make("high"),
id: Model.ID.make("claude"),
providerID: Provider.ID.make("anthropic"),
variant: Model.VariantID.make("high"),
},
}),
)
expect(yield* command.list()).toEqual([
CommandV2.Info.make({
Command.Info.make({
name: "review",
template: "Second",
description: "Review code",
model: {
id: ModelV2.ID.make("claude"),
providerID: ProviderV2.ID.make("anthropic"),
variant: ModelV2.VariantID.make("high"),
id: Model.ID.make("claude"),
providerID: Provider.ID.make("anthropic"),
variant: Model.VariantID.make("high"),
},
}),
])
@ -66,7 +66,7 @@ describe("CommandV2", () => {
it.effect("evaluates command template shell blocks", () =>
Effect.gen(function* () {
const command = yield* CommandV2.Service
const command = yield* Command.Service
yield* command.transform((editor) => {
editor.update("review", (command) => {
command.template = "Output: !`echo command-output`"

View file

@ -2,26 +2,26 @@ import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Agent } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { agentHost, host } from "../plugin/host"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node, Global.node])))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, FSUtil.node, Global.node])))
const decode = Schema.decodeUnknownSync(Config.Info)
const defaultPermissions = [
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
] satisfies PermissionV2.Ruleset
] satisfies Permission.Ruleset
test("rejects named agent color tokens", () => {
expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow()
@ -31,15 +31,15 @@ describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches POSIX paths against home-relative permissions", () =>
Effect.gen(function* () {
const permissions = yield* loadHomePermissions("/home/test")
expect(PermissionV2.evaluate("external_directory", "/home/test/p/opencode/src/*", permissions).effect).toBe(
expect(Permission.evaluate("external_directory", "/home/test/p/opencode/src/*", permissions).effect).toBe(
"allow",
)
expect(PermissionV2.evaluate("external_directory", "/home/test/cache/files/*", permissions).effect).toBe("deny")
expect(PermissionV2.evaluate("external_directory", "/some/~/path", permissions).effect).toBe("deny")
expect(PermissionV2.evaluate("external_directory", "$HOMELESS/private/*", permissions).effect).toBe("deny")
expect(Permission.evaluate("external_directory", "/home/test/cache/files/*", permissions).effect).toBe("deny")
expect(Permission.evaluate("external_directory", "/some/~/path", permissions).effect).toBe("deny")
expect(Permission.evaluate("external_directory", "$HOMELESS/private/*", permissions).effect).toBe("deny")
expect(permissions).toContainEqual({ action: "shell", resource: "$HOME/private/**", effect: "deny" })
expect(permissions).not.toContainEqual({ action: "shell", resource: "/home/test/private/**", effect: "deny" })
expect(PermissionV2.evaluate("shell", "$HOME/private/key", permissions).effect).toBe("deny")
expect(Permission.evaluate("shell", "$HOME/private/key", permissions).effect).toBe("deny")
}),
)
@ -47,9 +47,9 @@ describe("ConfigAgentPlugin.Plugin", () => {
Effect.gen(function* () {
const permissions = yield* loadHomePermissions("C:\\Users\\test")
expect(
PermissionV2.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
).toBe("allow")
expect(PermissionV2.evaluate("external_directory", "C:\\Users\\test\\cache\\files\\*", permissions).effect).toBe(
expect(Permission.evaluate("external_directory", "C:\\Users\\test\\cache\\files\\*", permissions).effect).toBe(
"deny",
)
}),
@ -57,8 +57,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
it.effect("applies all global permissions before agent-specific permissions", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const build = AgentV2.ID.make("build")
const agents = yield* Agent.Service
const build = Agent.ID.make("build")
yield* agents.transform((editor) =>
editor.update(build, (agent) => {
agent.mode = "primary"
@ -119,10 +119,10 @@ describe("ConfigAgentPlugin.Plugin", () => {
{ action: "read", resource: "*", effect: "allow" },
{ action: "bash", resource: "git *", effect: "allow" },
])
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
expect(Permission.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
expect(Permission.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
const reviewer = yield* agents.get(Agent.ID.make("reviewer"))
if (!reviewer) throw new Error("expected configured reviewer agent")
expect(reviewer).toMatchObject({
description: "Review changes",
@ -137,20 +137,20 @@ describe("ConfigAgentPlugin.Plugin", () => {
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "deny" },
])
expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([
expect(Permission.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
expect((yield* agents.get(Agent.ID.make("late")))?.permissions).toEqual([
...defaultPermissions,
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "allow" },
])
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
expect(yield* agents.get(Agent.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 agents = yield* Agent.Service
const config = Config.Service.of({
entries: () =>
Effect.succeed([
@ -194,7 +194,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
Effect.provideService(Config.Service, config),
)
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
const reviewer = yield* agents.get(Agent.ID.make("reviewer"))
if (!reviewer) throw new Error("expected configured reviewer agent")
expect(reviewer).toMatchObject({
system: "Review carefully.",
@ -215,8 +215,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
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 agents = yield* Agent.Service
const build = Agent.ID.make("build")
yield* agents.transform((editor) => editor.update(build, () => {}))
const config = Config.Service.of({
@ -277,7 +277,7 @@ Use native v2 fields.`,
await fs.writeFile(path.join(tmp.path, "agents", "disabled.md"), "---\ndisabled: true\n---\nDisabled")
await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
})
const agents = yield* AgentV2.Service
const agents = yield* Agent.Service
const config = Config.Service.of({
entries: () =>
Effect.succeed([
@ -293,21 +293,21 @@ Use native v2 fields.`,
Effect.provideService(Config.Service, config),
)
expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
model: { providerID: "openrouter", id: "openai/gpt-5" },
system: "Review carefully.",
description: "Markdown description",
request: { body: { temperature: 0.5 } },
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
})
expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({
expect(yield* agents.get(Agent.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
expect(yield* agents.get(Agent.ID.make("native"))).toMatchObject({
system: "Use native v2 fields.",
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
})
expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined()
expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
expect(yield* agents.get(Agent.ID.make("disabled"))).toBeUndefined()
expect(yield* agents.get(Agent.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
}),
),
),
@ -316,8 +316,8 @@ Use native v2 fields.`,
function loadHomePermissions(home: string) {
return Effect.gen(function* () {
const agents = yield* AgentV2.Service
const build = AgentV2.ID.make("build")
const agents = yield* Agent.Service
const build = Agent.ID.make("build")
yield* agents.transform((editor) => editor.update(build, () => {}))
const config = Config.Service.of({
entries: () =>

View file

@ -3,18 +3,18 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Effect, PubSub, Schema, Stream } from "effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { CommandV2 } from "@opencode-ai/core/command"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Command } from "@opencode-ai/core/command"
import { Agent } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp"
import { tmpdir } from "../fixture/tmpdir"
@ -22,7 +22,7 @@ import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([CommandV2.node, EventV2.node, FSUtil.node]), [
AppNodeBuilder.build(LayerNode.group([Command.node, Bus.node, FSUtil.node]), [
[MCP.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
@ -54,9 +54,9 @@ Review files`,
await fs.writeFile(path.join(tmp.path, "commands", "empty.md"), "")
})
const command = yield* CommandV2.Service
const events = yield* EventV2.Service
const update = yield* events.publish(ConfigSchema.Event.Updated, {})
const command = yield* Command.Service
const bus = yield* Bus.Service
const update = yield* bus.publish(ConfigSchema.Event.Updated, {})
const updates = yield* PubSub.unbounded<typeof update>()
yield* ConfigCommandPlugin.Plugin.effect(
host({
@ -84,20 +84,20 @@ Review files`,
)
expect(yield* command.list()).toEqual([
CommandV2.Info.make({
Command.Info.make({
name: "review",
template: "Review files",
description: "File review",
agent: AgentV2.ID.make("reviewer"),
agent: Agent.ID.make("reviewer"),
model: {
providerID: ProviderV2.ID.make("anthropic"),
id: ModelV2.ID.make("claude"),
variant: ModelV2.VariantID.make("high"),
providerID: Provider.ID.make("anthropic"),
id: Model.ID.make("claude"),
variant: Model.VariantID.make("high"),
},
subtask: true,
}),
CommandV2.Info.make({ name: "empty", template: "" }),
CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }),
Command.Info.make({ name: "empty", template: "" }),
Command.Info.make({ name: "nested/docs", template: "Write docs" }),
])
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again"))

View file

@ -15,11 +15,11 @@ import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { Integration } from "@opencode-ai/schema/integration"
@ -81,7 +81,7 @@ function testLayer(
),
),
)
return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
return AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
[Config.node, Config.configured(options)],
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
@ -194,8 +194,8 @@ describe("Config", () => {
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const events = yield* EventV2.Service
const changed = yield* events
const bus = yield* Bus.Service
const changed = yield* bus
.subscribe(ConfigSchema.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.sleep("10 millis")
@ -298,14 +298,14 @@ describe("Config", () => {
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const events = yield* EventV2.Service
const bus = yield* Bus.Service
expect(Config.latest(yield* config.entries(), "shell")).toBe("secret")
const updated = yield* events
const updated = yield* bus
.subscribe(ConfigSchema.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
key = "next"
yield* events.publish(Integration.Event.ConnectionUpdated, { integrationID })
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
expect(yield* Fiber.join(updated)).toHaveLength(1)
expect(Config.latest(yield* config.entries(), "shell")).toBe("next")
}).pipe(
@ -331,7 +331,7 @@ describe("Config", () => {
// V1 lists servers directly under `mcp`, so a file with only `$schema` + `mcp` still migrates.
expect(ConfigMigrateV1.isV1({ mcp: { context7: { type: "local", command: ["npx"] } } })).toBe(true)
expect(ConfigMigrateV1.isV1({ $schema: "x", mcp: { executor: { type: "remote", url: "https://x" } } })).toBe(true)
// V2 nests under `mcp.servers`, so it must not be misdetected and re-migrated.
// Current config nests under `mcp.servers`, so it must not be misdetected and re-migrated.
expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false)
expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false)
expect(ConfigMigrateV1.isV1({ mcp: { timeout: { execution: 1000 } } })).toBe(false)
@ -396,7 +396,7 @@ describe("Config", () => {
})
expect(migrated.providers?.bedrock).toMatchObject({
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock"),
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
settings: { region: "us-east-1", profile: "dev" },
headers: { "x-test": "1" },
body: { trace: true },
@ -1041,7 +1041,7 @@ describe("Config", () => {
},
})
expect(documents[0]?.info.providers?.openai).toMatchObject({
package: ProviderV2.aisdk("@ai-sdk/openai"),
package: Provider.aisdk("@ai-sdk/openai"),
settings: { apiKey: "secret", organization: "org" },
models: {
model: {
@ -1051,7 +1051,7 @@ describe("Config", () => {
},
})
expect(documents[0]?.info.providers?.anthropic).toMatchObject({
package: ProviderV2.aisdk("@ai-sdk/anthropic"),
package: Provider.aisdk("@ai-sdk/anthropic"),
models: {
model: {
settings: {

View file

@ -1,4 +1,4 @@
import { Plugin } from "@opencode-ai/plugin/v2"
import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "directory-plugin",

View file

@ -2,21 +2,20 @@ import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect } from "bun:test"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Plugin } from "@opencode-ai/core/plugin"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Logger } from "effect"
import { Database } from "../../src/database/database"
@ -24,7 +23,7 @@ import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])),
)
describe("PluginSupervisor config", () => {
@ -32,7 +31,7 @@ describe("PluginSupervisor config", () => {
withLocation(
{ plugins: ["-opencode.provider.*", "opencode.provider.openai"] },
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const plugins = yield* Plugin.Service
yield* ready()
expect(
(yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")),
@ -54,8 +53,8 @@ describe("PluginSupervisor config", () => {
},
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
expect(yield* agents.get(AgentV2.ID.make("configured"))).toMatchObject({
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded from config",
mode: "subagent",
})
@ -69,10 +68,10 @@ describe("PluginSupervisor config", () => {
{ plugins: [plugin, "-config-promise-plugin"] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
expect((yield* plugins.list()).map((item) => String(item.id))).not.toContain("config-promise-plugin")
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
}),
)
})
@ -83,7 +82,7 @@ describe("PluginSupervisor config", () => {
{ plugins: [plugin, `-${plugin}`] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* PluginV2.Service
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((item) => String(item.id))).toContain("config-promise-plugin")
}),
)
@ -102,8 +101,8 @@ describe("PluginSupervisor config", () => {
},
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
expect(yield* agents.get(AgentV2.ID.make("effect-configured"))).toMatchObject({
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("effect-configured"))).toMatchObject({
description: "Effect plugin from config",
mode: "subagent",
})
@ -133,8 +132,8 @@ describe("PluginSupervisor config", () => {
},
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
expect(yield* agents.get(AgentV2.ID.make("configured"))).toMatchObject({
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded after invalid plugins",
})
expect(output).toEqual([
@ -150,8 +149,8 @@ describe("PluginSupervisor config", () => {
undefined,
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
expect(yield* agents.get(AgentV2.ID.make("directory"))).toMatchObject({
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("directory"))).toMatchObject({
description: "Loaded from plugin directory",
})
}),
@ -164,26 +163,26 @@ describe("PluginSupervisor config", () => {
undefined,
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
const events = yield* EventV2.Service
const agents = yield* Agent.Service
const bus = yield* Bus.Service
const location = yield* Location.Service
const plugins = yield* PluginV2.Service
const plugins = yield* Plugin.Service
const file = path.join(location.directory, ".opencode", "plugin", "mutable.ts")
const first = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id
expect(first).toBeDefined()
expect((yield* agents.get(AgentV2.ID.make("mutable")))?.description).toBe("first")
expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("first")
yield* Effect.promise(async () => {
await fs.writeFile(file, mutablePlugin("second"))
const modified = new Date(Date.now() + 5_000)
await fs.utimes(file, modified, modified)
})
yield* events.publish(ConfigSchema.Event.Updated, {})
yield* bus.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(
Effect.gen(function* () {
const current = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id
return current === first && (yield* agents.get(AgentV2.ID.make("mutable")))?.description === "second"
return current === first && (yield* agents.get(Agent.ID.make("mutable")))?.description === "second"
}),
)
}),
@ -201,8 +200,8 @@ describe("PluginSupervisor config", () => {
{ plugins: ["-*"] },
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
expect(yield* agents.get(AgentV2.ID.make("directory"))).toBeUndefined()
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
}),
true,
),
@ -221,7 +220,7 @@ describe("PluginSupervisor config", () => {
},
Effect.gen(function* () {
yield* ready()
const registry = yield* PluginV2.Service
const registry = yield* Plugin.Service
const ids = (yield* registry.list()).map((plugin) => String(plugin.id))
expect(ids.indexOf("opencode.agent")).toBeLessThan(ids.indexOf("sdk-order"))
expect(ids.indexOf("sdk-order")).toBeLessThan(ids.indexOf("config-promise-plugin"))
@ -231,7 +230,7 @@ describe("PluginSupervisor config", () => {
const catalog = yield* Catalog.Service
expect(
(yield* catalog.model.get(ProviderV2.ID.make("configured"), ModelV2.ID.make("glm-5.2")))?.variants,
(yield* catalog.model.get(Provider.ID.make("configured"), Model.ID.make("glm-5.2")))?.variants,
).toEqual([
expect.objectContaining({ id: "high", headers: { custom: "true" } }),
expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
@ -248,12 +247,12 @@ describe("PluginSupervisor config", () => {
},
Effect.gen(function* () {
yield* ready()
const registry = yield* PluginV2.Service
const registry = yield* Plugin.Service
expect((yield* registry.list()).map((plugin) => String(plugin.id))).not.toContain("opencode.variant")
const catalog = yield* Catalog.Service
expect(
(yield* catalog.model.get(ProviderV2.ID.make("configured"), ModelV2.ID.make("glm-5.2")))?.variants,
(yield* catalog.model.get(Provider.ID.make("configured"), Model.ID.make("glm-5.2")))?.variants,
).toEqual([expect.objectContaining({ id: "high", headers: { custom: "true" } })])
}),
),
@ -304,7 +303,7 @@ function withLocation<A, E, R>(
}
function mutablePlugin(description: string) {
const plugin = pathToFileURL(path.join(import.meta.dir, "../../../plugin/src/v2/promise/index.ts")).href
const plugin = pathToFileURL(path.join(import.meta.dir, "../../../plugin/src/index.ts")).href
return `
import { Plugin } from ${JSON.stringify(plugin)}

View file

@ -3,10 +3,10 @@ import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigPolicyPlugin } from "@opencode-ai/core/config/plugin/policy"
import { EventV2 } from "@opencode-ai/core/event"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Bus } from "@opencode-ai/core/bus"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { Effect, Schema } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
@ -25,7 +25,7 @@ const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
})
const addPlugin = Effect.fn(function* (entries: () => Config.Entry[]) {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(
Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.sync(entries) })),
@ -37,9 +37,9 @@ describe("ConfigPolicyPlugin.Plugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.openai, () => {})
catalog.provider.update(ProviderV2.ID.anthropic, () => {})
catalog.provider.update(ProviderV2.ID.make("company-internal"), () => {})
catalog.provider.update(Provider.ID.openai, () => {})
catalog.provider.update(Provider.ID.anthropic, () => {})
catalog.provider.update(Provider.ID.make("company-internal"), () => {})
})
yield* addPlugin(() => [
policies(
@ -49,38 +49,38 @@ describe("ConfigPolicyPlugin.Plugin", () => {
),
])
expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined()
expect(yield* catalog.provider.get(ProviderV2.ID.anthropic)).toBeDefined()
expect(yield* catalog.provider.get(ProviderV2.ID.make("company-internal"))).toBeDefined()
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
expect(yield* catalog.provider.get(Provider.ID.anthropic)).toBeDefined()
expect(yield* catalog.provider.get(Provider.ID.make("company-internal"))).toBeDefined()
}),
)
it.effect("prevents project policy from overriding user-global policy", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {}))
yield* catalog.transform((catalog) => catalog.provider.update(Provider.ID.openai, () => {}))
yield* addPlugin(() => [
policies({ effect: "deny", resource: "openai" }),
policies({ effect: "allow", resource: "openai" }),
])
expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined()
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
}),
)
it.live("reloads changed policies", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
const bus = yield* Bus.Service
let entries: Config.Entry[] = [policies({ effect: "deny", resource: "openai" })]
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {}))
yield* catalog.transform((catalog) => catalog.provider.update(Provider.ID.openai, () => {}))
yield* addPlugin(() => entries)
expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined()
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
entries = [policies({ effect: "allow", resource: "openai" })]
yield* events.publish(ConfigSchema.Event.Updated, {})
yield* bus.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(
catalog.provider.get(ProviderV2.ID.openai).pipe(Effect.map((provider) => provider !== undefined)),
catalog.provider.get(Provider.ID.openai).pipe(Effect.map((provider) => provider !== undefined)),
)
}),
)

View file

@ -5,17 +5,17 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* (config: Config.Interface) {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config))
})
@ -52,8 +52,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("defaults custom models to agent capabilities", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat")
const providerID = Provider.ID.make("custom")
const modelID = Model.ID.make("chat")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
@ -81,9 +81,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("preserves catalog capabilities unless config overrides them", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("custom")
const inheritedID = ModelV2.ID.make("inherited")
const overriddenID = ModelV2.ID.make("overridden")
const providerID = Provider.ID.make("custom")
const inheritedID = Model.ID.make("inherited")
const overriddenID = Model.ID.make("overridden")
yield* catalog.transform((draft) => {
draft.model.update(providerID, inheritedID, (model) => {
model.capabilities = { tools: false, input: ["text"], output: ["text"] }
@ -132,8 +132,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("keeps configured model variant bodies unchanged", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next")
const providerID = Provider.ID.opencode
const modelID = Model.ID.make("alpha-gpt-next")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
@ -184,8 +184,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("keeps layered model variant bodies unchanged", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next")
const providerID = Provider.ID.opencode
const modelID = Model.ID.make("alpha-gpt-next")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
@ -232,8 +232,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat")
const providerID = Provider.ID.make("custom")
const modelID = Model.ID.make("chat")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
@ -318,7 +318,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
const provider = required(yield* catalog.provider.get(providerID))
const model = required(yield* catalog.model.get(providerID, modelID))
expect((yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
expect((yield* catalog.model.default())?.id).toBe(Model.ID.make("default"))
expect(provider.name).toBe("Renamed")
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
type: "env",
@ -330,7 +330,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(provider.settings).toEqual({ baseURL: "https://example.test" })
expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.id).toBe(modelID)
expect(model.modelID).toBe(ModelV2.ID.make("api-chat"))
expect(model.modelID).toBe(Model.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.compatibility).toEqual({ reasoningField: "vendor_reasoning" })
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
@ -350,8 +350,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(model.settings).toEqual({ baseURL: "https://example.test", retained: true })
expect(model.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.variants?.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("fast"),
ModelV2.VariantID.make("slow"),
Model.VariantID.make("fast"),
Model.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

@ -1,22 +1,22 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { CommandV2 } from "@opencode-ai/core/command"
import { Command } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
import { ConfigReferencePlugin } from "@opencode-ai/core/config/plugin/reference"
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Global } from "@opencode-ai/util/global"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { Reference } from "@opencode-ai/core/reference"
import { SkillV2 } from "@opencode-ai/core/skill"
import { Skill } from "@opencode-ai/core/skill"
import { Effect, Schema } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
@ -28,13 +28,13 @@ const document = path.join(import.meta.dir, "opencode.json")
describe("config plugin reloads", () => {
it.live("reloads config-backed domains without reloading external plugins", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const events = yield* EventV2.Service
const plugins = yield* PluginV2.Service
const commands = yield* Command.Service
const bus = yield* Bus.Service
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
const skills = yield* SkillV2.Service
const skills = yield* Skill.Service
const host = yield* PluginHost.make(plugins)
let entries: Config.Entry[] = [config("first")]
const service = Config.Service.of({ entries: () => Effect.sync(() => entries) })
@ -47,26 +47,26 @@ describe("config plugin reloads", () => {
yield* setup(ConfigReferencePlugin.Plugin.effect(host))
yield* setup(ConfigProviderPlugin.Plugin.effect(host))
expect((yield* agents.get(AgentV2.ID.make("first")))?.description).toBe("First agent")
expect((yield* agents.get(Agent.ID.make("first")))?.description).toBe("First agent")
expect((yield* commands.get("first"))?.description).toBe("First command")
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
).toBe(true)
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
expect(yield* catalog.provider.get(ProviderV2.ID.make("first"))).toBeDefined()
expect(yield* catalog.provider.get(Provider.ID.make("first"))).toBeDefined()
entries = [config("second")]
yield* events.publish(ConfigSchema.Event.Updated, {})
yield* bus.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(
Effect.gen(function* () {
return (
(yield* agents.get(AgentV2.ID.make("first"))) === undefined &&
(yield* agents.get(AgentV2.ID.make("second")))?.description === "Second agent" &&
(yield* agents.get(Agent.ID.make("first"))) === undefined &&
(yield* agents.get(Agent.ID.make("second")))?.description === "Second agent" &&
(yield* commands.get("first")) === undefined &&
(yield* commands.get("second"))?.description === "Second command" &&
(yield* references.list()).some((reference) => reference.name === "second") &&
(yield* catalog.provider.get(ProviderV2.ID.make("first"))) === undefined &&
(yield* catalog.provider.get(ProviderV2.ID.make("second"))) !== undefined
(yield* catalog.provider.get(Provider.ID.make("first"))) === undefined &&
(yield* catalog.provider.get(Provider.ID.make("second"))) !== undefined
)
}),
)

View file

@ -6,7 +6,7 @@ import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SkillV2 } from "@opencode-ai/core/skill"
import { Skill } from "@opencode-ai/core/skill"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
@ -18,8 +18,8 @@ describe("ConfigSkillPlugin.Plugin", () => {
it.effect("registers configured skill directories and URLs", () =>
Effect.gen(function* () {
const directory = AbsolutePath.make("/repo/packages/app")
const sources: SkillV2.Source[] = []
const transform = Effect.fnUntraced(function* (update: (draft: SkillV2.Draft) => void | Effect.Effect<void>) {
const sources: Skill.Source[] = []
const transform = Effect.fnUntraced(function* (update: (draft: Skill.Draft) => void | Effect.Effect<void>) {
const result = update({
source: (source) => {
sources.push(source)
@ -61,29 +61,29 @@ describe("ConfigSkillPlugin.Plugin", () => {
)
expect(sources).toEqual([
SkillV2.DirectorySource.make({
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.claude", "skills")),
}),
SkillV2.DirectorySource.make({
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.agents", "skills")),
}),
SkillV2.DirectorySource.make({
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.opencode", "skill")),
}),
SkillV2.DirectorySource.make({
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.opencode", "skills")),
}),
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
SkillV2.DirectorySource.make({
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/home/test", "shared-skills")),
}),
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/opt/skills") }),
SkillV2.UrlSource.make({ type: "url", url: "https://example.test/skills/" }),
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/opt/skills") }),
Skill.UrlSource.make({ type: "url", url: "https://example.test/skills/" }),
])
}),
)

View file

@ -28,8 +28,8 @@ import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migrat
import canonicalToolResultsMigration from "@opencode-ai/core/database/migration/20260722170000_canonical_tool_results"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Bus } from "@opencode-ai/core/bus"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionSchema } from "@opencode-ai/core/session/schema"
@ -968,13 +968,13 @@ describe("DatabaseMigration", () => {
)
const database = Layer.succeed(Database.Service, { db })
yield* EventV2.Service.use((service) =>
yield* Bus.Service.use((service) =>
service.publish(SessionV1.Event.Updated, {
sessionID: SessionSchema.ID.make("session"),
info: {
id: SessionSchema.ID.make("session"),
slug: "session",
projectID: ProjectV2.ID.global,
projectID: Project.ID.global,
directory: "/project",
title: "After",
version: "test",
@ -983,7 +983,7 @@ describe("DatabaseMigration", () => {
}),
).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([EventV2.node, SessionProjector.node]), [[Database.node, database]]),
AppNodeBuilder.build(LayerNode.group([Bus.node, SessionProjector.node]), [[Database.node, database]]),
),
)
@ -1280,7 +1280,7 @@ describe("DatabaseMigration", () => {
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
const projectID = ProjectV2.ID.make("codec_project")
const projectID = Project.ID.make("codec_project")
const worktree = AbsolutePath.make("C:\\Repo\\Thing")
const sandbox = AbsolutePath.make("C:\\Repo\\Thing\\sandbox")
const directory = "C:\\Repo\\Thing\\packages\\api"
@ -1291,7 +1291,7 @@ describe("DatabaseMigration", () => {
db
.insert(ProjectTable)
.values({
id: ProjectV2.ID.make("invalid_path"),
id: Project.ID.make("invalid_path"),
worktree: AbsolutePath.make("not-absolute"),
sandboxes: [],
time_created: 1,

View file

@ -3,7 +3,7 @@ import { Effect, Layer, Logger } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { EventLogger } from "@opencode-ai/core/event-logger"
import { Agent } from "@opencode-ai/schema/agent"
import { Catalog } from "@opencode-ai/schema/catalog"
@ -11,7 +11,7 @@ import { Command } from "@opencode-ai/schema/command"
import { Config } from "@opencode-ai/schema/config"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
const UnlistedUpdated = EventV2.ephemeral({ type: "test.updated", schema: {} })
const UnlistedUpdated = Bus.ephemeral({ type: "test.updated", schema: {} })
describe("EventLogger", () => {
test("logs explicitly listed updated events", async () => {
@ -21,15 +21,15 @@ describe("EventLogger", () => {
})
await Effect.gen(function* () {
const events = yield* EventV2.Service
yield* events.publish(Agent.Event.Updated, {})
yield* events.publish(Catalog.Event.Updated, {})
yield* events.publish(Command.Event.Updated, {})
yield* events.publish(Config.Event.Updated, {})
yield* events.publish(McpEvent.StatusChanged, { server: "example" })
yield* events.publish(UnlistedUpdated, {})
const bus = yield* Bus.Service
yield* bus.publish(Agent.Event.Updated, {})
yield* bus.publish(Catalog.Event.Updated, {})
yield* bus.publish(Command.Event.Updated, {})
yield* bus.publish(Config.Event.Updated, {})
yield* bus.publish(McpEvent.StatusChanged, { server: "example" })
yield* bus.publish(UnlistedUpdated, {})
}).pipe(
Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, EventLogger.node]))),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, EventLogger.node]))),
Effect.provide(Logger.layer([logger])),
Effect.scoped,
Effect.runPromise,

View file

@ -6,7 +6,7 @@ import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } fr
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
@ -21,7 +21,7 @@ const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
const configLayer = Layer.succeed(
Config.Service,
@ -66,9 +66,9 @@ function withTmp<A, E, R>(
function wait(check: (event: WatcherEvent) => boolean) {
return Effect.gen(function* () {
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const deferred = yield* Deferred.make<WatcherEvent>()
const fiber = yield* events.subscribe(FileSystem.Event.Changed).pipe(
const fiber = yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.runForEach((event) => {
if (!check(event.data)) return Effect.void
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
@ -217,7 +217,7 @@ describeWatcher("LocationWatcher", () => {
it.live("cleanup stops publishing events", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@ -229,9 +229,9 @@ describeWatcher("LocationWatcher", () => {
)
const file = path.join(tmp.path, "after-dispose.txt")
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
Effect.provideService(EventV2.Service, events),
Effect.provideService(Bus.Service, bus),
)
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))),
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))),
)
it.live("ignores .git/index changes", () =>

View file

@ -2,12 +2,12 @@ import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Form } from "@opencode-ai/core/form"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { testEffect } from "./lib/effect"
const forms = AppNodeBuilder.build(LayerNode.group([EventV2.node, Form.node]))
const forms = AppNodeBuilder.build(LayerNode.group([Bus.node, Form.node]))
const it = testEffect(forms)
const formID = Form.ID.create("frm_test")
@ -22,9 +22,9 @@ describe("Form", () => {
it.effect("returns a terminal cancelled state from ask", () =>
Effect.gen(function* () {
const service = yield* Form.Service
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const created = yield* Deferred.make<Form.Info>()
const unsubscribe = yield* events.listen((event) =>
const unsubscribe = yield* bus.listen((event) =>
event.type === Form.Event.Created.type
? Deferred.succeed(created, (event.data as { readonly form: Form.Info }).form).pipe(Effect.asVoid)
: Effect.void,
@ -311,8 +311,8 @@ describe("Form", () => {
it.effect("cleans up created forms when event publication fails", () =>
Effect.gen(function* () {
const service = yield* Form.Service
const events = yield* EventV2.Service
const unsubscribe = yield* events.listen((event) =>
const bus = yield* Bus.Service
const unsubscribe = yield* bus.listen((event) =>
event.type === Form.Event.Created.type ? Effect.die("create listener failed") : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
@ -328,9 +328,9 @@ describe("Form", () => {
it.effect("keeps forms pending when reply event publication fails", () =>
Effect.gen(function* () {
const service = yield* Form.Service
const events = yield* EventV2.Service
const bus = yield* Bus.Service
yield* service.create(input)
const unsubscribe = yield* events.listen((event) =>
const unsubscribe = yield* bus.listen((event) =>
event.type === Form.Event.Replied.type ? Effect.die("reply listener failed") : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)

View file

@ -6,15 +6,15 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { Generate } from "@opencode-ai/core/generate"
import { Integration } from "@opencode-ai/core/integration"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ID, Info, Ref } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { Npm } from "@opencode-ai/util/npm"
import { Effect, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
const selected = ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("gemini")),
package: ProviderV2.aisdk("@ai-sdk/google"),
const selected = Info.make({
...Info.default(Provider.ID.make("test-provider"), ID.make("gemini")),
package: Provider.aisdk("@ai-sdk/google"),
})
const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
@ -89,7 +89,7 @@ it.effect("loads dynamic AI SDK models", () =>
const generate = yield* Generate.Service
const result = yield* generate.text({
prompt: "Return exactly OK",
model: ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }),
model: Ref.make({ providerID: selected.providerID, id: selected.id }),
})
expect(result).toBe("OK")
@ -99,11 +99,11 @@ it.effect("loads dynamic AI SDK models", () =>
resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
const result = yield* resolver.resolve(ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }))
const result = yield* resolver.resolve(Ref.make({ providerID: selected.providerID, id: selected.id }))
expect(result).toEqual({
model: runtime,
ref: ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }),
ref: Ref.make({ providerID: selected.providerID, id: selected.id }),
capabilities: selected.capabilities,
cost: selected.cost,
})

View file

@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { CopilotModels } from "@opencode-ai/core/github-copilot/models"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
test("defensively syncs advertised Copilot models", async () => {
const server = Bun.serve({
@ -48,28 +48,28 @@ test("defensively syncs advertised Copilot models", async () => {
})
try {
const existing = ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.githubCopilot, ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
const existing = Model.Info.make({
...Model.Info.default(Provider.ID.githubCopilot, Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
name: "GPT-5 local",
})
const stale = ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.githubCopilot, ModelV2.ID.make("stale")),
modelID: ModelV2.ID.make("stale"),
const stale = Model.Info.make({
...Model.Info.default(Provider.ID.githubCopilot, Model.ID.make("stale")),
modelID: Model.ID.make("stale"),
})
const models = await CopilotModels.get(server.url.origin, {}, [existing, stale])
const model = models.get(ModelV2.ID.make("gpt-5"))
const model = models.get(Model.ID.make("gpt-5"))
expect(model?.name).toBe("GPT-5 local")
expect(model?.settings).toMatchObject({ baseURL: server.url.origin, endpoint: "responses" })
expect(model?.cost[0]).toMatchObject({ input: 0, output: 0, cache: { read: 0, write: 0 } })
expect(model?.variants.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
Model.VariantID.make("low"),
Model.VariantID.make("high"),
])
expect(models.get(ModelV2.ID.make("utility"))?.enabled).toBe(false)
expect(models.has(ModelV2.ID.make("stale"))).toBe(false)
expect(models.has(ModelV2.ID.make("incomplete"))).toBe(false)
expect(models.get(Model.ID.make("utility"))?.enabled).toBe(false)
expect(models.has(Model.ID.make("stale"))).toBe(false)
expect(models.has(Model.ID.make("incomplete"))).toBe(false)
} finally {
await server.stop(true)
}

View file

@ -4,7 +4,8 @@ import { Effect, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Instructions } from "@opencode-ai/core/instructions"
import { Project } from "@opencode-ai/core/project"
@ -16,7 +17,7 @@ import { SessionSchema } from "@opencode-ai/core/session/schema"
import { InstructionBlobTable, InstructionStateTable, SessionTable } from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node])))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node])))
const source = (name: string, read: Effect.Effect<string | Instructions.Unavailable | Instructions.Removed>) =>
Instructions.make({
@ -51,7 +52,7 @@ const setup = (sessionID: SessionSchema.ID) =>
})
.run()
.pipe(Effect.orDie)
return { db, events: yield* EventV2.Service }
return { db, events: yield* Bus.Service }
})
const instructionEvents = (db: Database.Interface["db"], sessionID: SessionSchema.ID) =>
@ -90,7 +91,7 @@ describe("InstructionState", () => {
}),
),
])
const published: EventV2.Payload[] = []
const published: Event.Payload[] = []
const unsubscribe = yield* events.listen((event) =>
Effect.sync(() => {
if (event.type === "session.instructions.updated") published.push(event)
@ -146,7 +147,7 @@ describe("InstructionState", () => {
}),
),
])
const published: EventV2.Payload[] = []
const published: Event.Payload[] = []
const unsubscribe = yield* events.listen((event) =>
Effect.sync(() => {
if (event.type === "session.instructions.updated") published.push(event)

View file

@ -5,11 +5,11 @@ import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Integration } from "@opencode-ai/core/integration"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node])))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
const failingCredentialNode = makeGlobalNode({
service: Credential.Service,
layer: Layer.succeed(
@ -26,7 +26,7 @@ const failingCredentialNode = makeGlobalNode({
deps: [],
})
const failingIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Integration.node, EventV2.node]), [[Credential.node, failingCredentialNode]]),
AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [[Credential.node, failingCredentialNode]]),
)
function eventually<A, E, R>(
@ -135,7 +135,7 @@ describe("Integration", () => {
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const integrationID = Integration.ID.make("openai")
yield* integrations.transform((editor) =>
editor.method.update({
@ -143,7 +143,7 @@ describe("Integration", () => {
method: { type: "key", label: "API key" },
}),
)
const updated = yield* events
const updated = yield* bus
.subscribe(Integration.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow

View file

@ -1,7 +1,7 @@
import { Image } from "@opencode-ai/core/image"
import { Effect, Layer } from "effect"
/** Passthrough resizer for tests that build ToolRegistry.node without a Location. */
/** Passthrough resizer for tests that build Tool.node without a Location. */
export const imagePassthrough = Layer.mock(Image.Service, {
normalize: (_resource, content) => Effect.succeed(content),
})

View file

@ -1,23 +1,23 @@
import { AgentV2 } from "@opencode-ai/core/agent"
import type { PermissionV2 } from "@opencode-ai/core/permission"
import { Agent } from "@opencode-ai/core/agent"
import type { Permission } from "@opencode-ai/core/permission"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Tool } from "@opencode-ai/core/tool/tool"
import { Tools } from "@opencode-ai/core/tool/tools"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import type { SessionError } from "@opencode-ai/schema/session-error"
import { Tool } from "@opencode-ai/core/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Effect, type Scope } from "effect"
import { host } from "../plugin/host"
export const toolIdentity = {
agent: AgentV2.ID.make("build"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_tool_test"),
}
export const toolDefinitions = (registry: ToolRegistry.Interface, permissions?: PermissionV2.Ruleset) =>
export const toolDefinitions = (registry: Tool.Interface, permissions?: Permission.Ruleset) =>
registry.snapshot(permissions).pipe(Effect.map((toolSet) => toolSet.definitions))
export function waitForTool(
registry: ToolRegistry.Interface,
registry: Tool.Interface,
name: string,
remaining = 1000,
): Effect.Effect<void, Error> {
@ -33,10 +33,10 @@ export function waitForTool(
}
export function waitForCodeModeTool(
registry: ToolRegistry.Interface,
registry: Tool.Interface,
path: string,
remaining = 1000,
): Effect.Effect<ToolRegistry.ToolSet, Error> {
): Effect.Effect<Tool.Snapshot, Error> {
return Effect.gen(function* () {
const toolSet = yield* registry.snapshot()
if (toolSet.codeModeCatalog?.some((tool) => tool.path === path)) return toolSet
@ -59,9 +59,9 @@ export const registerToolPlugin = <R>(
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
},
overrides: Parameters<typeof host>[0] = {},
): Effect.Effect<void, never, R | Tools.Service | Scope.Scope> =>
): Effect.Effect<void, never, R | Tool.Service | Scope.Scope> =>
Effect.gen(function* () {
const tools = yield* Tools.Service
const tools = yield* Tool.Service
const context = host({
...overrides,
session: {
@ -69,29 +69,31 @@ export const registerToolPlugin = <R>(
},
tool: {
transform: (callback) =>
Effect.gen(function* () {
const registrations: Array<{
readonly name: string
readonly tool: Tool.Any
readonly options?: Tool.RegisterOptions
}> = []
callback({
add: (name, tool, options) => {
registrations.push({ name, tool, ...(options ? { options } : {}) })
},
})
yield* Effect.forEach(
registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true },
).pipe(Effect.orDie)
return { dispose: Effect.void }
}),
tools
.transform((draft) => callback({ add: (tool) => draft.add(tool) }))
.pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
})
yield* plugin.effect(context)
})
export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
registry.snapshot().pipe(Effect.flatMap((toolSet) => toolSet.execute(input)))
export interface ToolExecution {
readonly status: "completed" | "error"
readonly output?: any
readonly content?: ReadonlyArray<Tool.Content>
readonly metadata?: Tool.Metadata
readonly error?: SessionError.Error
}
export const executeTool = (
registry: Tool.Interface,
input: Parameters<Tool.Snapshot["execute"]>[0],
): Effect.Effect<ToolExecution> =>
registry.snapshot().pipe(
Effect.flatMap((tools) => tools.execute(input)),
Effect.map((result) => ({ status: "completed" as const, ...result }) satisfies ToolExecution),
Effect.catchTag("Tool.Error", (error) =>
Effect.succeed({ status: "error" as const, error: toSessionError(error) } satisfies ToolExecution),
),
)

View file

@ -2,36 +2,35 @@ import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Deferred, Effect, Equal, Fiber, Hash, RcMap, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Plugin } from "@opencode-ai/core/plugin"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Model } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolDefinitions, waitForTool } from "./lib/tool"
import { Database } from "../src/database/database"
import { EventV2 } from "../src/event"
import { Bus } from "../src/bus"
import { Reference } from "../src/reference"
import { ToolRegistry } from "../src/tool/registry"
import { Tool } from "../src/tool"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node])))
const itWithSdk = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])),
)
describe("LocationServiceMap", () => {
@ -44,7 +43,7 @@ describe("LocationServiceMap", () => {
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
const locations = yield* LocationServiceMap.Service
const id = AgentV2.ID.make("persistent-sdk-agent")
const id = Agent.ID.make("persistent-sdk-agent")
const plugin = EffectPlugin.define({
id: "persistent-sdk-plugin",
effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})),
@ -55,7 +54,7 @@ describe("LocationServiceMap", () => {
const read = Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
const agents = yield* AgentV2.Service
const agents = yield* Agent.Service
return yield* agents.get(id)
})
@ -101,7 +100,7 @@ describe("LocationServiceMap", () => {
)
const explorer = yield* Effect.gen(function* () {
const agents = yield* AgentV2.Service
const agents = yield* Agent.Service
return yield* agents.resolve("explore")
}).pipe(Effect.provide(context))
@ -193,8 +192,8 @@ describe("LocationServiceMap", () => {
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* Deferred.await(firstStarted)
const events = yield* EventV2.Service
const updated = yield* events.subscribe(Config.Event.Updated).pipe(
const bus = yield* Bus.Service
const updated = yield* bus.subscribe(Config.Event.Updated).pipe(
Stream.filter((event) => event.location?.directory === dir.path),
Stream.runHead,
Effect.forkChild({ startImmediately: true }),
@ -235,11 +234,11 @@ describe("LocationServiceMap", () => {
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
const events = yield* EventV2.Service
const bus = yield* Bus.Service
yield* Effect.forEach(
Array.from({ length: 5 }),
() => events.publish(SdkPlugins.Updated, {}).pipe(Effect.andThen(Effect.sleep("50 millis"))),
() => bus.publish(SdkPlugins.Updated, {}).pipe(Effect.andThen(Effect.sleep("50 millis"))),
{ discard: true },
)
expect(flushFiber.pollUnsafe()).toBeUndefined()
@ -270,7 +269,7 @@ describe("LocationServiceMap", () => {
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
expect(activations.count).toBe(1)
yield* EventV2.Service.use((events) => events.publish(Config.Event.Updated, {})).pipe(Effect.provide(context))
yield* Bus.Service.use((bus) => bus.publish(Config.Event.Updated, {})).pipe(Effect.provide(context))
yield* Effect.sleep("200 millis")
expect(activations.count).toBe(1)
@ -370,7 +369,7 @@ describe("LocationServiceMap", () => {
fs.writeFile(path.join(dir.path, "opencode.json"), JSON.stringify({ plugins: ["-*", "opencode.agent"] })),
)
const plugins = yield* Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const plugins = yield* Plugin.Service
yield* (yield* PluginSupervisor.Service).flush
return yield* plugins.list()
}).pipe(
@ -396,7 +395,7 @@ describe("LocationServiceMap", () => {
const file = path.join(dir.path, "opencode.json")
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
yield* Effect.gen(function* () {
const registry = yield* PluginV2.Service
const registry = yield* Plugin.Service
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
@ -449,25 +448,25 @@ describe("LocationServiceMap", () => {
Effect.scoped(
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const firstRef = Location.Ref.make({ directory: AbsolutePath.make(first.path) })
const secondRef = Location.Ref.make({ directory: AbsolutePath.make(second.path) })
const firstContext = yield* locations.contextEffect(firstRef)
const secondContext = yield* locations.contextEffect(secondRef)
const received = { first: 0, second: 0 }
yield* events.subscribe(Config.Event.Updated).pipe(
yield* bus.subscribe(Config.Event.Updated).pipe(
Stream.runForEach(() => Effect.sync(() => received.first++)),
Effect.provideContext(firstContext),
Effect.forkScoped({ startImmediately: true }),
)
yield* events.subscribe(Config.Event.Updated).pipe(
yield* bus.subscribe(Config.Event.Updated).pipe(
Stream.runForEach(() => Effect.sync(() => received.second++)),
Effect.provideContext(secondContext),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("10 millis")
yield* events.publish(Config.Event.Updated, {}, { location: firstRef })
yield* bus.publish(Config.Event.Updated, {}, { location: firstRef })
yield* Effect.sleep("10 millis")
expect(received).toEqual({ first: 1, second: 0 })
@ -538,12 +537,12 @@ describe("LocationServiceMap", () => {
).pipe(
Effect.flatMap(([blocked, allowed]) =>
Effect.gen(function* () {
const update = (directory: string, providerID: ProviderV2.ID) =>
const update = (directory: string, providerID: Provider.ID) =>
Effect.gen(function* () {
yield* Reference.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
const registry = yield* ToolRegistry.Service
const registry = yield* Tool.Service
// Tool plugins register during the forked PluginSupervisor boot; wait for
// every expected tool rather than relying on batch ordering.
yield* Effect.forEach(
@ -573,8 +572,8 @@ describe("LocationServiceMap", () => {
),
)
const blockedID = ProviderV2.ID.make("blocked-location")
const allowedID = ProviderV2.ID.make("allowed-location")
const blockedID = Provider.ID.make("blocked-location")
const allowedID = Provider.ID.make("allowed-location")
const blockedState = yield* update(blocked.path, blockedID)
expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true)
expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false)
@ -641,13 +640,13 @@ describe("LocationServiceMap", () => {
)
const failure = yield* SessionRunnerModel.Service.use((models) =>
models.resolve(
SessionV2.Info.make({
id: SessionV2.ID.make("ses_unavailable_model"),
projectID: ProjectV2.ID.global,
Session.Info.make({
id: Session.ID.make("ses_unavailable_model"),
projectID: Project.ID.global,
title: "test",
model: {
id: ModelV2.ID.make("chat"),
providerID: ProviderV2.ID.make("unavailable"),
id: Model.ID.make("chat"),
providerID: Provider.ID.make("unavailable"),
},
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
@ -678,25 +677,25 @@ describe("LocationServiceMap", () => {
const resolved = yield* Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => {
editor.provider.update(ProviderV2.ID.make("aliased"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai")
editor.provider.update(Provider.ID.make("aliased"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai")
})
editor.model.update(ProviderV2.ID.make("aliased"), ModelV2.ID.make("fast"), (model) => {
editor.model.update(Provider.ID.make("aliased"), Model.ID.make("fast"), (model) => {
// Catalog id and package model id intentionally differ, like gpt-5.5-fast -> gpt-5.5.
model.modelID = ModelV2.ID.make("base")
model.variants = [{ id: ModelV2.VariantID.make("high") }]
model.modelID = Model.ID.make("base")
model.variants = [{ id: Model.VariantID.make("high") }]
})
})
const models = yield* SessionRunnerModel.Service
return yield* models.resolve(
SessionV2.Info.make({
id: SessionV2.ID.make("ses_aliased_model"),
projectID: ProjectV2.ID.global,
Session.Info.make({
id: Session.ID.make("ses_aliased_model"),
projectID: Project.ID.global,
title: "test",
model: {
id: ModelV2.ID.make("fast"),
providerID: ProviderV2.ID.make("aliased"),
variant: ModelV2.VariantID.make("high"),
id: Model.ID.make("fast"),
providerID: Provider.ID.make("aliased"),
variant: Model.VariantID.make("high"),
},
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
@ -707,10 +706,10 @@ describe("LocationServiceMap", () => {
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)))
expect(resolved.ref).toEqual(
ModelV2.Ref.make({
id: ModelV2.ID.make("fast"),
providerID: ProviderV2.ID.make("aliased"),
variant: ModelV2.VariantID.make("high"),
Model.Ref.make({
id: Model.ID.make("fast"),
providerID: Provider.ID.make("aliased"),
variant: Model.VariantID.make("high"),
}),
)
expect(String(resolved.model.id)).toBe("base")
@ -726,7 +725,7 @@ describe("LocationServiceMap", () => {
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const plugins = yield* Plugin.Service
const reviewer = EffectPlugin.define({
id: "reviewer",
effect: (ctx) =>
@ -741,7 +740,7 @@ describe("LocationServiceMap", () => {
})
yield* plugins.activate([{ ...reviewer, version: "1" }])
expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({
expect(yield* (yield* Agent.Service).get(Agent.ID.make("reviewer"))).toMatchObject({
description: "Reviews code",
mode: "subagent",
})

View file

@ -4,10 +4,10 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect"
const workspaceID = WorkspaceV2.ID.make("wrk_test")
const workspaceID = Workspace.ID.make("wrk_test")
const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID }
const projectLayer = Layer.succeed(
Project.Service,

View file

@ -1,18 +1,18 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { MCP } from "@opencode-ai/core/mcp/index"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Permission } from "@opencode-ai/core/permission"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { it } from "./lib/effect"
import { readInitial, readUpdate } from "./lib/instructions"
const build = AgentV2.ID.make("build")
const build = Agent.ID.make("build")
const selection = (permissions: PermissionV2.Ruleset = []) => {
const info = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions })
const selection = (permissions: Permission.Ruleset = []) => {
const info = Agent.Info.make({ ...Agent.Info.empty(build), permissions })
return { id: info.id, info }
}

View file

@ -16,18 +16,18 @@ import { Config } from "@opencode-ai/core/config"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { MCPClient } from "@opencode-ai/core/mcp/client"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Tool } from "@opencode-ai/core/tool"
import { Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
@ -35,8 +35,8 @@ import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { executeTool, toolDefinitions, toolIdentity, waitForCodeModeTool, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
let decision: Effect.Effect<void, Permission.Error> = Effect.void
let calls = 0
type ResourcePage = {
@ -182,14 +182,14 @@ function resourceMcpLayer(
}),
),
Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
Layer.mock(EventV2.Service, {
Layer.mock(Bus.Service, {
subscribe: () => Stream.never,
publish: (definition, data) => {
const event = {
id: EventV2.ID.create(),
id: Event.ID.create(),
type: definition.type,
data,
} as EventV2.Payload<typeof definition>
} as Event.Payload<typeof definition>
if (event.type !== Form.Event.Created.type || !onFormCreated) return Effect.succeed(event)
return onFormCreated(Schema.decodeUnknownSync(Form.Event.Created.data)(data).form).pipe(Effect.as(event))
},
@ -285,7 +285,7 @@ const mcp = Layer.mock(MCP.Service, {
})
}),
})
const permissions = Layer.mock(PermissionV2.Service, {
const permissions = Layer.mock(Permission.Service, {
assert: (input) =>
Effect.gen(function* () {
if (!assertion) return yield* Effect.die("Permission test is not initialized")
@ -293,13 +293,12 @@ const permissions = Layer.mock(PermissionV2.Service, {
yield* decision
}),
})
const events = Layer.mock(EventV2.Service, { subscribe: () => Stream.never })
const events = Layer.mock(Bus.Service, { subscribe: () => Stream.never })
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, McpTool.node]), [
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [
[MCP.node, mcp],
[PermissionV2.node, permissions],
[EventV2.node, events],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Permission.node, permissions],
[Bus.node, events],
[Image.node, imagePassthrough],
]),
)
@ -801,7 +800,7 @@ test("serializes concurrent MCP lifecycle operations", async () => {
it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
@ -818,7 +817,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const registry = yield* Tool.Service
yield* waitForTool(registry, "direct_lookup")
const definitions = yield* toolDefinitions(registry)
const execute = definitions.find((tool) => tool.name === "execute")
@ -832,38 +831,35 @@ it.effect("advertises MCP tools directly when Code Mode is disabled for the serv
// success whose text happens to describe an error.
it.effect("fails the call when MCP reports isError", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* ToolRegistry.Service
const registry = yield* Tool.Service
yield* waitForTool(registry, "direct_fail")
const execution = yield* executeTool(registry, {
sessionID: SessionV2.ID.make("ses_mcp_is_error"),
sessionID: Session.ID.make("ses_mcp_is_error"),
...toolIdentity,
call: { type: "tool-call", id: "call_mcp_is_error", name: "direct_fail", input: {} },
})
expect(execution).toMatchObject({ status: "error", error: { message: "search index unavailable" } })
expect(execution.content).toBeUndefined()
}),
)
// Baseline (PLAN.md step 1): mixed MCP text and media content must reach the model intact.
it.effect("preserves MCP text and media content for the model", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* ToolRegistry.Service
const registry = yield* Tool.Service
yield* waitForTool(registry, "direct_media")
const execution = yield* executeTool(registry, {
sessionID: SessionV2.ID.make("ses_mcp_media"),
sessionID: Session.ID.make("ses_mcp_media"),
...toolIdentity,
call: { type: "tool-call", id: "call_mcp_media", name: "direct_media", input: {} },
})
expect(execution.status).toBe("completed")
if (execution.status !== "completed") return
expect(execution.output).toBe("rendered chart")
expect(execution.content).toMatchObject([
{ type: "text", text: "rendered chart" },
@ -875,14 +871,14 @@ it.effect("preserves MCP text and media content for the model", () =>
it.effect("waits for permission before calling an MCP tool", () =>
Effect.gen(function* () {
calls = 0
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
assertion = yield* Deferred.make<Permission.AssertInput>()
const permission = yield* Deferred.make<void>()
decision = Deferred.await(permission)
const registry = yield* ToolRegistry.Service
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
const fiber = yield* toolSet.execute({
sessionID: SessionV2.ID.make("ses_mcp_permission"),
sessionID: Session.ID.make("ses_mcp_permission"),
...toolIdentity,
call: {
type: "tool-call",
@ -896,7 +892,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
resources: ["*"],
save: ["*"],
metadata: {},
sessionID: SessionV2.ID.make("ses_mcp_permission"),
sessionID: Session.ID.make("ses_mcp_permission"),
agent: toolIdentity.agent,
source: {
type: "tool",
@ -915,13 +911,13 @@ it.effect("waits for permission before calling an MCP tool", () =>
it.effect("does not call MCP when permission is blocked", () =>
Effect.gen(function* () {
calls = 0
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
const registry = yield* ToolRegistry.Service
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.fail(new Permission.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
const execution = yield* toolSet.execute({
sessionID: SessionV2.ID.make("ses_mcp_blocked"),
sessionID: Session.ID.make("ses_mcp_blocked"),
...toolIdentity,
call: {
type: "tool-call",
@ -930,7 +926,6 @@ it.effect("does not call MCP when permission is blocked", () =>
input: { code: "return await tools.demo.search({})" },
},
})
expect(execution.status).toBe("completed")
expect(execution.content).toEqual([{ type: "text", text: "Unable to execute demo_search" }])
expect(execution.metadata).toEqual({
toolCalls: [{ tool: "demo.search", status: "error" }],

View file

@ -5,25 +5,25 @@ import { Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Compatibility, ID, Info, VariantID } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { it } from "./lib/effect"
interface ModelOptions {
readonly modelID?: string
readonly compatibility?: ModelV2.Compatibility
readonly settings?: ModelV2.Info["settings"]
readonly headers?: ModelV2.Info["headers"]
readonly body?: ModelV2.Info["body"]
readonly variants?: ModelV2.Info["variants"]
readonly compatibility?: Compatibility
readonly settings?: Info["settings"]
readonly headers?: Info["headers"]
readonly body?: Info["body"]
readonly variants?: Info["variants"]
}
const model = (packageName: string | undefined, options: ModelOptions = {}) =>
ModelV2.Info.make({
id: ModelV2.ID.make("test-model"),
modelID: ModelV2.ID.make(options.modelID ?? "api-test-model"),
providerID: ProviderV2.ID.make("test-provider"),
Info.make({
id: ID.make("test-model"),
modelID: ID.make(options.modelID ?? "api-test-model"),
providerID: Provider.ID.make("test-provider"),
name: "Test model",
compatibility: options.compatibility,
package: packageName,
@ -42,12 +42,12 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
describe("ModelResolver", () => {
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
})
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
expect(catalog.id).toBe(ModelV2.ID.make("test-model"))
expect(catalog.id).toBe(ID.make("test-model"))
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
expect(resolved.route).toMatchObject({
id: "openai-responses",
@ -65,7 +65,7 @@ describe("ModelResolver", () => {
it.effect("keeps catalog apiKey credentials out of provider JSON", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "secret", baseURL: "https://openai.example/v1" },
}),
)
@ -79,7 +79,7 @@ describe("ModelResolver", () => {
it.effect("treats an empty configured API key as omitted", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
}),
)
@ -98,7 +98,7 @@ describe("ModelResolver", () => {
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
compatibility: { reasoningField: "vendor_reasoning" },
settings: {
apiKey: "settings-secret",
@ -128,11 +128,11 @@ describe("ModelResolver", () => {
it.effect("overlays selected OpenAI variant settings and bodies", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
variants: [
{
id: ModelV2.VariantID.make("high"),
id: VariantID.make("high"),
settings: { reasoningEffort: "high" },
headers: { "x-variant": "high" },
body: {
@ -143,7 +143,7 @@ describe("ModelResolver", () => {
},
],
})
const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high"))
const resolved = yield* ModelResolver.resolveModel(catalog, VariantID.make("high"))
expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
expect(resolved.route.defaults.http?.body).toEqual({
@ -160,18 +160,18 @@ describe("ModelResolver", () => {
it.effect("overlays selected OpenAI-compatible variant bodies", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
const catalog = model(Provider.aisdk("@ai-sdk/openai-compatible"), {
settings: { baseURL: "https://compatible.example/v1" },
variants: [
{
id: ModelV2.VariantID.make("high"),
id: VariantID.make("high"),
settings: {},
headers: {},
body: { store: false, reasoning_effort: "high" },
},
],
})
const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high"))
const resolved = yield* ModelResolver.resolveModel(catalog, VariantID.make("high"))
expect(resolved.route.defaults.http?.body).toEqual({
custom_extension: { enabled: true },
@ -183,10 +183,10 @@ describe("ModelResolver", () => {
it.effect("rejects an explicit unavailable variant during model resolution", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
})
const failure = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("unknown")).pipe(Effect.flip)
const failure = yield* ModelResolver.resolveModel(catalog, VariantID.make("unknown")).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.VariantUnavailableError",
@ -200,18 +200,18 @@ describe("ModelResolver", () => {
it.effect("overlays selected Anthropic variant settings", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/anthropic"), {
const catalog = model(Provider.aisdk("@ai-sdk/anthropic"), {
settings: { baseURL: "https://anthropic.example/v1" },
variants: [
{
id: ModelV2.VariantID.make("high"),
id: VariantID.make("high"),
settings: { thinking: { type: "enabled", budgetTokens: 12000 } },
headers: {},
body: {},
},
],
})
const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high"))
const resolved = yield* ModelResolver.resolveModel(catalog, VariantID.make("high"))
expect(resolved.route.defaults.http?.body).toEqual({
custom_extension: { enabled: true },
@ -225,7 +225,7 @@ describe("ModelResolver", () => {
it.effect("maps catalog Anthropic AI SDK models into native routes", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/anthropic"), {
model(Provider.aisdk("@ai-sdk/anthropic"), {
settings: { baseURL: "https://anthropic.example/v1" },
}),
)
@ -241,7 +241,7 @@ describe("ModelResolver", () => {
it.effect("uses resolved credentials for bearer auth", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
body: {},
@ -265,7 +265,7 @@ describe("ModelResolver", () => {
Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "configured-secret", baseURL: "https://openai.example/v1" },
headers: {},
body: {},
@ -288,7 +288,7 @@ describe("ModelResolver", () => {
it.effect("does not project OAuth account metadata into the request body", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
body: {},
@ -310,7 +310,7 @@ describe("ModelResolver", () => {
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
body: {},
@ -395,7 +395,7 @@ describe("ModelResolver", () => {
it.effect("maps legacy OpenAI organization and project settings to headers", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { organization: "org_123", project: "proj_123" },
}),
)
@ -410,7 +410,7 @@ describe("ModelResolver", () => {
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
body: {},
@ -441,7 +441,7 @@ describe("ModelResolver", () => {
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
body: {},
@ -473,7 +473,7 @@ describe("ModelResolver", () => {
it.effect("loads dynamic native provider packages through the injected package loader", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
@ -510,7 +510,7 @@ describe("ModelResolver", () => {
it.effect("maps OAuth credentials to native provider auth settings", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
@ -549,12 +549,12 @@ describe("ModelResolver", () => {
it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/google"), {
model(Provider.aisdk("@ai-sdk/google"), {
modelID: "gemini-api-model",
settings: { project: "test" },
headers: { "x-aisdk": "header" },
@ -568,7 +568,7 @@ describe("ModelResolver", () => {
id: "test-model",
modelID: "gemini-api-model",
providerID: "test-provider",
package: ProviderV2.aisdk("@ai-sdk/google"),
package: Provider.aisdk("@ai-sdk/google"),
settings: { project: "test", apiKey: "fallback-secret" },
headers: { "x-aisdk": "header" },
body: { custom: true },
@ -589,7 +589,7 @@ describe("ModelResolver", () => {
it.effect("rejects AISDK packages without an available loader", () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/google"), {
model(Provider.aisdk("@ai-sdk/google"), {
settings: { baseURL: "https://google.example/v1" },
}),
).pipe(Effect.flip)
@ -607,12 +607,12 @@ describe("ModelResolver", () => {
it.effect("drops an empty API key before loading an AISDK package", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/google"), {
model(Provider.aisdk("@ai-sdk/google"), {
settings: { apiKey: "", baseURL: "https://google.example/v1" },
}),
undefined,
@ -629,7 +629,7 @@ describe("ModelResolver", () => {
it.effect("reports whether a catalog model declares a provider package", () =>
Effect.sync(() => {
expect(ModelResolver.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true)
expect(ModelResolver.supported(model(Provider.aisdk("@ai-sdk/openai")))).toBe(true)
expect(ModelResolver.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true)
expect(ModelResolver.supported(model(undefined))).toBe(false)
}),

View file

@ -1,23 +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"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
const decode = Schema.decodeUnknownSync(ModelV2.Ref)
const decode = Schema.decodeUnknownSync(Model.Ref)
describe("ModelV2.Ref", () => {
describe("Model.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"),
id: Model.ID.make("claude-sonnet"),
providerID: Provider.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"),
id: Model.ID.make("claude-sonnet"),
providerID: Provider.ID.make("anthropic"),
variant: Model.VariantID.make("high"),
})
})
})

View file

@ -6,9 +6,9 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { ModelV2 } from "@opencode-ai/core/model"
import { Model } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { it } from "./lib/effect"
import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
@ -16,10 +16,10 @@ import path from "path"
const cacheFile = path.join(Global.Path.cache, "models.json")
test("normalizes permissive interleaved values to compatibility", () => {
expect(ModelV2.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
expect(ModelV2.compatibility({ field: "vendor_reasoning" })).toEqual({ reasoningField: "vendor_reasoning" })
expect(ModelV2.compatibility(true)).toBeUndefined()
expect(ModelV2.compatibility(false)).toBeUndefined()
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
expect(Model.compatibility({ field: "vendor_reasoning" })).toEqual({ reasoningField: "vendor_reasoning" })
expect(Model.compatibility(true)).toBeUndefined()
expect(Model.compatibility(false)).toBeUndefined()
})
const fixture = {
@ -47,15 +47,15 @@ const fixture = {
const fixtureSnapshot = [
{
info: {
id: ProviderV2.ID.make("acme"),
id: Provider.ID.make("acme"),
name: "Acme",
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
},
models: [
{
id: ModelV2.ID.make("acme-1"),
modelID: ModelV2.ID.make("acme-1"),
providerID: ProviderV2.ID.make("acme"),
id: Model.ID.make("acme-1"),
modelID: Model.ID.make("acme-1"),
providerID: Provider.ID.make("acme"),
name: "Acme One",
compatibility: { reasoningField: "vendor_reasoning" },
family: undefined,
@ -109,15 +109,15 @@ const fixture2 = {
const fixture2Snapshot = [
{
info: {
id: ProviderV2.ID.make("beta"),
id: Provider.ID.make("beta"),
name: "Beta",
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
},
models: [
{
id: ModelV2.ID.make("beta-1"),
modelID: ModelV2.ID.make("beta-1"),
providerID: ProviderV2.ID.make("beta"),
id: Model.ID.make("beta-1"),
modelID: Model.ID.make("beta-1"),
providerID: Provider.ID.make("beta"),
name: "Beta One",
family: undefined,
package: undefined,

View file

@ -8,13 +8,13 @@ import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Job } from "@opencode-ai/core/job"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable } from "@opencode-ai/core/session/sql"
@ -40,10 +40,10 @@ const it = testEffect(
LayerNode.group([
MoveSession.node,
Database.node,
EventV2.node,
Bus.node,
ProjectDirectories.node,
Project.node,
SessionV2.node,
Session.node,
SessionProjector.node,
SessionStore.node,
]),
@ -86,7 +86,7 @@ describe("MoveSession", () => {
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = SessionV2.ID.make("ses_move")
const sessionID = Session.ID.make("ses_move")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
@ -142,7 +142,7 @@ describe("MoveSession", () => {
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = SessionV2.ID.make("ses_move_nested")
const sessionID = Session.ID.make("ses_move_nested")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
@ -164,7 +164,7 @@ describe("MoveSession", () => {
.run()
.pipe(Effect.orDie)
const missing = yield* SessionV2.Service.use((service) =>
const missing = yield* Session.Service.use((service) =>
service.move({ sessionID, directory: abs("packages") }).pipe(Effect.flip),
)
expect(missing._tag).toBe("Session.DestinationNotFoundError")
@ -202,7 +202,7 @@ describe("MoveSession", () => {
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id
const sessionID = SessionV2.ID.make("ses_move_project")
const sessionID = Session.ID.make("ses_move_project")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
@ -224,7 +224,7 @@ describe("MoveSession", () => {
.run()
.pipe(Effect.orDie)
yield* SessionV2.Service.use((service) =>
yield* Session.Service.use((service) =>
service.move({ sessionID, directory: destination }),
)
@ -266,7 +266,7 @@ describe("MoveSession", () => {
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "unrelated\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = SessionV2.ID.make("ses_move_nested_checkout")
const sessionID = Session.ID.make("ses_move_nested_checkout")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)

View file

@ -1,19 +1,19 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Agent } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Job } from "@opencode-ai/core/job"
import { Location } from "@opencode-ai/core/location"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionTable } from "@opencode-ai/core/permission/sql"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { eq } from "drizzle-orm"
@ -28,17 +28,17 @@ const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
Bus.node,
SessionStore.node,
PermissionSaved.node,
AgentV2.node,
PermissionV2.node,
Agent.node,
Permission.node,
]),
[[Location.node, current]],
),
)
function setup(rules: PermissionV2.Ruleset = []) {
function setup(rules: Permission.Ruleset = []) {
return Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
@ -50,7 +50,7 @@ function setup(rules: PermissionV2.Ruleset = []) {
yield* db
.insert(SessionTable)
.values({
id: SessionV2.ID.make("ses_test"),
id: Session.ID.make("ses_test"),
project_id: Project.ID.global,
slug: "test",
directory: "/project",
@ -65,35 +65,35 @@ function setup(rules: PermissionV2.Ruleset = []) {
})
}
function setRules(rules: PermissionV2.Ruleset) {
function setRules(rules: Permission.Ruleset) {
return Effect.gen(function* () {
const agents = yield* AgentV2.Service
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(AgentV2.ID.make("test"), (agent) => {
editor.update(Agent.ID.make("test"), (agent) => {
agent.permissions = [...rules]
}),
)
})
}
function assertion(input: Partial<PermissionV2.AssertInput> = {}) {
function assertion(input: Partial<Permission.AssertInput> = {}) {
return {
id: PermissionV2.ID.create("per_test"),
sessionID: SessionV2.ID.make("ses_test"),
id: Permission.ID.create("per_test"),
sessionID: Session.ID.make("ses_test"),
action: "read",
resources: ["src/index.ts"],
...input,
} satisfies PermissionV2.AssertInput
} satisfies Permission.AssertInput
}
function waitForRequest() {
return Effect.gen(function* () {
const service = yield* PermissionV2.Service
const events = yield* EventV2.Service
const asked = yield* Deferred.make<PermissionV2.Request>()
const unsubscribe = yield* events.listen((event) =>
event.type === PermissionV2.Event.Asked.type
? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid)
const service = yield* Permission.Service
const bus = yield* Bus.Service
const asked = yield* Deferred.make<Permission.Request>()
const unsubscribe = yield* bus.listen((event) =>
event.type === Permission.Event.Asked.type
? Deferred.succeed(asked, event.data as Permission.Request).pipe(Effect.asVoid)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
@ -103,53 +103,53 @@ function waitForRequest() {
})
}
describe("PermissionV2", () => {
describe("Permission", () => {
it.effect("returns the evaluated effect and only queues prompts", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const service = yield* PermissionV2.Service
expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow" })
const service = yield* Permission.Service
expect(yield* service.ask(assertion())).toEqual({ id: Permission.ID.create("per_test"), effect: "allow" })
expect(yield* service.list()).toEqual([])
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "deny" })
expect(yield* service.ask(assertion())).toEqual({ id: Permission.ID.create("per_test"), effect: "deny" })
expect(yield* service.list()).toEqual([])
yield* setRules([])
expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "ask" })
expect(yield* service.get(PermissionV2.ID.create("per_test"))).toBeDefined()
expect(yield* service.ask(assertion())).toEqual({ id: Permission.ID.create("per_test"), effect: "ask" })
expect(yield* service.get(Permission.ID.create("per_test"))).toBeDefined()
}),
)
it.effect("evaluates against an explicit provider-turn agent", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const agents = yield* AgentV2.Service
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(AgentV2.ID.make("reviewer"), (agent) => {
editor.update(Agent.ID.make("reviewer"), (agent) => {
agent.permissions.push({ action: "read", resource: "*", effect: "deny" })
}),
)
const service = yield* PermissionV2.Service
const service = yield* Permission.Service
expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" })
expect(yield* service.ask(assertion({ agent: AgentV2.ID.make("reviewer") }))).toMatchObject({ effect: "deny" })
expect(yield* service.ask(assertion({ agent: Agent.ID.make("reviewer") }))).toMatchObject({ effect: "deny" })
yield* agents.transform((editor) =>
editor.update(AgentV2.ID.make("reviewer"), (agent) => {
editor.update(Agent.ID.make("reviewer"), (agent) => {
agent.permissions = []
}),
)
expect(yield* service.ask(assertion({ agent: AgentV2.ID.make("reviewer") }))).toMatchObject({ effect: "ask" })
expect(yield* service.get(PermissionV2.ID.create("per_test"))).not.toHaveProperty("agent")
expect(yield* service.ask(assertion({ agent: Agent.ID.make("reviewer") }))).toMatchObject({ effect: "ask" })
expect(yield* service.get(Permission.ID.create("per_test"))).not.toHaveProperty("agent")
}),
)
it.effect("allows and denies from explicit rules without asking", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const service = yield* PermissionV2.Service
const service = yield* Permission.Service
yield* service.assert(assertion())
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
const blocked = yield* service.assert(assertion()).pipe(Effect.flip)
expect(blocked).toBeInstanceOf(PermissionV2.BlockedError)
expect(blocked).toBeInstanceOf(Permission.BlockedError)
expect(yield* service.list()).toEqual([])
}),
)
@ -160,7 +160,7 @@ describe("PermissionV2", () => {
{ action: "*", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
])
const service = yield* PermissionV2.Service
const service = yield* Permission.Service
expect(yield* service.ask(assertion({ resources: ["tool_123"] }))).toMatchObject({ effect: "allow" })
expect(
@ -176,19 +176,19 @@ describe("PermissionV2", () => {
yield* db
.update(SessionTable)
.set({ agent: null })
.where(eq(SessionTable.id, SessionV2.ID.make("ses_test")))
.where(eq(SessionTable.id, Session.ID.make("ses_test")))
.run()
.pipe(Effect.orDie)
const agents = yield* AgentV2.Service
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(AgentV2.ID.make("build"), (agent) => {
editor.update(Agent.ID.make("build"), (agent) => {
agent.permissions = [{ action: "custom", resource: "*", effect: "allow" }]
}),
)
const service = yield* PermissionV2.Service
const service = yield* Permission.Service
expect(yield* service.ask(assertion({ action: "custom", resources: ["*"] }))).toEqual({
id: PermissionV2.ID.create("per_test"),
id: Permission.ID.create("per_test"),
effect: "allow",
})
expect(yield* service.list()).toEqual([])
@ -202,17 +202,17 @@ describe("PermissionV2", () => {
yield* db
.update(SessionTable)
.set({ agent: null })
.where(eq(SessionTable.id, SessionV2.ID.make("ses_test")))
.where(eq(SessionTable.id, Session.ID.make("ses_test")))
.run()
.pipe(Effect.orDie)
const agents = yield* AgentV2.Service
const agents = yield* Agent.Service
yield* agents.transform((editor) => {
editor.remove(AgentV2.ID.make("test"))
editor.remove(AgentV2.ID.make("build"))
editor.remove(Agent.ID.make("test"))
editor.remove(Agent.ID.make("build"))
})
const service = yield* PermissionV2.Service
expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "deny" })
const service = yield* Permission.Service
expect(yield* service.ask(assertion())).toEqual({ id: Permission.ID.create("per_test"), effect: "deny" })
expect(yield* service.list()).toEqual([])
}),
)
@ -220,13 +220,13 @@ describe("PermissionV2", () => {
it.effect("evaluates bash with the normal configured-rule semantics", () =>
Effect.gen(function* () {
yield* setup([{ action: "*", resource: "*", effect: "allow" }])
const service = yield* PermissionV2.Service
const service = yield* Permission.Service
const bash = assertion({ action: "bash", resources: ["pwd"] })
expect(yield* service.ask(bash)).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow" })
expect(yield* service.ask(bash)).toEqual({ id: Permission.ID.create("per_test"), effect: "allow" })
yield* setRules([])
expect(yield* service.ask(bash)).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "ask" })
expect(yield* service.get(PermissionV2.ID.create("per_test"))).toBeDefined()
expect(yield* service.ask(bash)).toEqual({ id: Permission.ID.create("per_test"), effect: "ask" })
expect(yield* service.get(Permission.ID.create("per_test"))).toBeDefined()
}),
)
@ -236,16 +236,16 @@ describe("PermissionV2", () => {
const saved = yield* PermissionSaved.Service
yield* saved.add({ projectID: Project.ID.global, action: "bash", resources: ["pwd"] })
const service = yield* PermissionV2.Service
const service = yield* Permission.Service
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toEqual({
id: PermissionV2.ID.create("per_test"),
id: Permission.ID.create("per_test"),
effect: "allow",
})
expect(yield* service.list()).toEqual([])
yield* setRules([{ action: "bash", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toEqual({
id: PermissionV2.ID.create("per_test"),
id: Permission.ID.create("per_test"),
effect: "deny",
})
}),
@ -257,7 +257,7 @@ describe("PermissionV2", () => {
const { service, fiber, request } = yield* waitForRequest()
expect(yield* service.list()).toEqual([request])
expect(yield* service.forSession(request.sessionID)).toEqual([request])
expect(yield* service.forSession(SessionV2.ID.make("ses_other"))).toEqual([])
expect(yield* service.forSession(Session.ID.make("ses_other"))).toEqual([])
expect(yield* service.get(request.id)).toEqual(request)
yield* service.reply({ requestID: request.id, reply: "once" })
yield* Fiber.join(fiber)
@ -277,7 +277,7 @@ describe("PermissionV2", () => {
if (exit._tag === "Failure")
expect(
exit.cause.reasons.some(
(reason) => Cause.isDieReason(reason) && reason.defect instanceof PermissionV2.DeclinedError,
(reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError,
),
).toBe(true)
expect(yield* service.list()).toEqual([])
@ -287,12 +287,12 @@ describe("PermissionV2", () => {
it.effect("stores and removes saved resources for a project", () =>
Effect.gen(function* () {
yield* setup()
const service = yield* PermissionV2.Service
const asked = yield* Deferred.make<PermissionV2.Request>()
const events = yield* EventV2.Service
const unsubscribe = yield* events.listen((event) =>
event.type === PermissionV2.Event.Asked.type
? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid)
const service = yield* Permission.Service
const asked = yield* Deferred.make<Permission.Request>()
const bus = yield* Bus.Service
const unsubscribe = yield* bus.listen((event) =>
event.type === Permission.Event.Asked.type
? Deferred.succeed(asked, event.data as Permission.Request).pipe(Effect.asVoid)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
@ -308,7 +308,7 @@ describe("PermissionV2", () => {
const saved = yield* PermissionSaved.Service
const id = (yield* saved.list())[0]!.id
expect(yield* saved.list()).toEqual([{ id, projectID: Project.ID.global, action: "read", resource: "src/*" }])
yield* service.assert(assertion({ id: PermissionV2.ID.create("per_next"), resources: ["src/next.ts"] }))
yield* service.assert(assertion({ id: Permission.ID.create("per_next"), resources: ["src/next.ts"] }))
yield* saved.remove(id)
expect(yield* saved.list()).toEqual([])
}),

View file

@ -1,16 +1,14 @@
import { describe, expect } from "bun:test"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin"
import { AgentV2 } from "@opencode-ai/core/agent"
import { EventV2 } from "@opencode-ai/core/event"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool/tool"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Tool } from "@opencode-ai/core/tool"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"
@ -20,11 +18,11 @@ class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSec
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
describe("PluginV2", () => {
describe("Plugin", () => {
it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const events = yield* EventV2.Service
const plugins = yield* Plugin.Service
const bus = yield* Bus.Service
const host = yield* PluginHost.make(plugins)
const received = yield* host.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
@ -33,7 +31,7 @@ describe("PluginV2", () => {
)
yield* Effect.sleep("10 millis")
yield* events.publish(ConfigSchema.Event.Updated, {})
yield* bus.publish(ConfigSchema.Event.Updated, {})
expect((yield* Fiber.join(received)).valueOrUndefined?.type).toBe("config.updated")
}),
@ -41,12 +39,12 @@ describe("PluginV2", () => {
it.effect("replaces plugins by ID and version", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const events = yield* EventV2.Service
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
const bus = yield* Bus.Service
let description = "first"
let updates = 0
const unsubscribe = yield* events.listen((event) =>
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type === Plugin.Event.Updated.type) updates++
}),
@ -67,19 +65,19 @@ describe("PluginV2", () => {
yield* plugins.activate([versioned(managed(), "1")])
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("first")
description = "second"
yield* plugins.activate([versioned(managed(), "2")])
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
description = "third"
yield* plugins.activate([versioned(managed(), "2")])
expect(updates).toBe(2)
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
yield* plugins.activate([])
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
expect(updates).toBe(3)
yield* unsubscribe
}),
@ -87,7 +85,7 @@ describe("PluginV2", () => {
it.effect("rejects duplicate IDs before replacing active plugins", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const plugins = yield* Plugin.Service
const active = Plugin.ID.make("active")
const duplicate = "duplicate"
yield* plugins.activate([{ id: active, version: "1", effect: () => Effect.void }])
@ -106,8 +104,8 @@ describe("PluginV2", () => {
it.effect("skips failed plugins and loads the rest", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
let fail = true
const good = EffectPlugin.define({
id: "good",
@ -130,7 +128,7 @@ describe("PluginV2", () => {
yield* plugins.activate([versioned(good), versioned(bad)])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("loaded")
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
fail = false
yield* plugins.activate([versioned(good), versioned(bad, "2")])
@ -140,8 +138,8 @@ describe("PluginV2", () => {
it.effect("restores the previous plugin when its replacement fails", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
const previous = EffectPlugin.define({
id: "managed",
effect: (ctx) =>
@ -170,14 +168,14 @@ describe("PluginV2", () => {
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("managed") }])
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("previous")
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
}),
)
it.effect("deactivates a plugin when replacement and restoration fail", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
let loads = 0
const previous = EffectPlugin.define({
id: "managed",
@ -202,13 +200,13 @@ describe("PluginV2", () => {
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([])
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
}),
)
it.effect("closes the previous generation in reverse order", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const plugins = yield* Plugin.Service
const closed: string[] = []
yield* plugins.activate(
["first", "second"].map((id) => ({
@ -226,7 +224,7 @@ describe("PluginV2", () => {
it.effect("isolates plugins from ambient services", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const plugins = yield* Plugin.Service
let visible = true
const plugin = EffectPlugin.define({
id: "isolated",
@ -245,22 +243,22 @@ describe("PluginV2", () => {
it.effect("registers location tools through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const plugin = EffectPlugin.define({
id: "tool-plugin",
effect: (ctx) =>
ctx.tool
.transform((draft) =>
draft.add(
"plugin_tool",
Tool.make({
({
name: "plugin_tool",
options: { codemode: false },
description: "Plugin tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ output: { ok: true } }),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie),
@ -276,10 +274,12 @@ describe("PluginV2", () => {
it.effect("namespaces tool names and routes codemode registrations through execute", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
const tool = (description: string) =>
Tool.make({
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const tool = (name: string, description: string, options?: Tool.Options) =>
({
name,
options,
description,
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
@ -290,9 +290,9 @@ describe("PluginV2", () => {
effect: (ctx) =>
ctx.tool
.transform((draft) => {
draft.add("plain", tool("Plain"), { codemode: false })
draft.add("look/up", tool("Lookup"), { namespace: "context7", codemode: false })
draft.add("search", tool("Search"), { namespace: "context7" })
draft.add(tool("plain", "Plain", { codemode: false }))
draft.add(tool("look/up", "Lookup", { namespace: "context7", codemode: false }))
draft.add(tool("search", "Search", { namespace: "context7" }))
})
.pipe(Effect.orDie),
})
@ -309,8 +309,8 @@ describe("PluginV2", () => {
it.effect("fires before/after tool hooks with mutable events around execution", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
const seen: {
before?: unknown
@ -324,15 +324,15 @@ describe("PluginV2", () => {
yield* ctx.tool
.transform((draft) =>
draft.add(
"echo",
Tool.make({
({
name: "echo",
options: { codemode: false },
description: "Echo",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) =>
Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
@ -352,12 +352,15 @@ describe("PluginV2", () => {
seen.after = {
input: event.input,
status: event.status,
content: event.content,
metadata: event.metadata,
content: event.status === "completed" ? event.result.content : undefined,
metadata: event.status === "completed" ? event.result.metadata : event.error.metadata,
}
if (event.status !== "completed") return
event.content = [{ type: "text", text: "after-mutated" }]
event.metadata = { rewritten: true }
event.result = {
...event.result,
content: [{ type: "text", text: "after-mutated" }],
metadata: { rewritten: true },
}
}),
)
.pipe(Effect.asVoid)
@ -365,7 +368,7 @@ describe("PluginV2", () => {
yield* ctx.tool
.hook("execute.after", (event) =>
Effect.sync(() => {
if (event.status === "completed") (event.content as unknown as unknown[]).splice(0)
if (event.status === "completed" && Array.isArray(event.result.content)) event.result.content.splice(0)
}),
)
.pipe(Effect.asVoid)
@ -376,8 +379,8 @@ describe("PluginV2", () => {
const toolSet = yield* registry.snapshot()
const execution = yield* toolSet.execute({
sessionID: SessionV2.ID.make("ses_hooks"),
agent: AgentV2.ID.make("build"),
sessionID: Session.ID.make("ses_hooks"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_hooks"),
call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } },
})

View file

@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { CommandV2 } from "@opencode-ai/core/command"
import { Command } from "@opencode-ai/core/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
@ -15,12 +15,12 @@ const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory }, { projectDirectory: project })),
)
const it = testEffect(AppNodeBuilder.build(CommandV2.node, [[Location.node, locationLayer]]))
const it = testEffect(AppNodeBuilder.build(Command.node, [[Location.node, locationLayer]]))
describe("CommandPlugin.Plugin", () => {
it.effect("registers built-in init and review commands", () =>
Effect.gen(function* () {
const command = yield* CommandV2.Service
const command = yield* Command.Service
yield* CommandPlugin.Plugin.effect(
host({
command: {

View file

@ -1,26 +1,25 @@
import { AgentV2 } from "@opencode-ai/core/agent"
import { Agent } from "@opencode-ai/core/agent"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Catalog } from "@opencode-ai/core/catalog"
import { CommandV2 } from "@opencode-ai/core/command"
import { Command } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/util/npm"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Reference } from "@opencode-ai/core/reference"
import { SkillV2 } from "@opencode-ai/core/skill"
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Skill } from "@opencode-ai/core/skill"
import { Tool } from "@opencode-ai/core/tool"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Effect, Layer } from "effect"
import { tempLocationLayer } from "../fixture/location"
@ -41,21 +40,21 @@ export const PluginTestLayer = AppNodeBuilder.build(
Location.node,
Npm.node,
Credential.node,
EventV2.node,
Bus.node,
Form.node,
LayerNodePlatform.httpClient,
PluginV2.node,
AgentV2.node,
Plugin.node,
Agent.node,
AISDK.node,
Catalog.node,
CommandV2.node,
Command.node,
Integration.node,
PluginRuntime.node,
PluginHooks.node,
Reference.node,
SkillV2.node,
ToolHooks.node,
ToolRegistry.toolsNode,
Skill.node,
PluginHooks.node,
Tool.node,
WebSearch.node,
]),
[

View file

@ -1,4 +1,4 @@
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Plugin } from "@opencode-ai/plugin/effect"
import { Effect } from "effect"
export default Plugin.define({

View file

@ -1,4 +1,4 @@
import { Plugin } from "@opencode-ai/plugin/v2"
import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "config-promise-plugin",

View file

@ -1,4 +1,4 @@
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Plugin } from "@opencode-ai/plugin/effect"
import { Effect } from "effect"
export default Plugin.define({

View file

@ -1,5 +1,6 @@
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Plugin } from "@opencode-ai/plugin/effect"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { Effect } from "effect"
export default Plugin.define({
@ -8,14 +9,14 @@ export default Plugin.define({
ctx.catalog
.transform((catalog) => {
catalog.provider.update("configured", (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
})
catalog.model.update("configured", "glm-5.2", (model) => {
model.modelID = "glm-5.2"
model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
model.modelID = Model.ID.make("glm-5.2")
model.package = Provider.aisdk("@ai-sdk/openai-compatible")
model.variants = [
{
id: "high",
id: Model.VariantID.make("high"),
settings: {},
headers: { custom: "true" },
body: {},

View file

@ -1,21 +1,15 @@
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { Model } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WebSearch } from "@opencode-ai/core/websearch"
import type {
CredentialOAuth,
IntegrationCommandMethod,
IntegrationEnvMethod,
IntegrationKeyMethod,
IntegrationOAuthMethod,
} from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect"
type Overrides = Partial<Omit<Plugin.Context, "options" | "session">> & {
@ -41,7 +35,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
get: () => Effect.die("unused catalog.provider.get"),
},
model: {
get: () => Effect.die("unused catalog.model.get"),
list: () => Effect.die("unused catalog.model.list"),
default: () => Effect.die("unused catalog.model.default"),
},
@ -116,9 +109,22 @@ export function host(overrides: Overrides = {}): Plugin.Context {
}
}
export function agentHost(agent: AgentV2.Interface): Plugin.Context["agent"] {
export function agentHost(agent: Agent.Interface): Plugin.Context["agent"] {
return {
get: (id) => agent.get(AgentV2.ID.make(id)).pipe(Effect.map((value) => value && agentInfo(value))),
get: (input) =>
agent.get(input.agentID).pipe(
Effect.flatMap((value) =>
value
? Effect.succeed({
location: new Location.Info({
directory: AbsolutePath.make("/"),
project: { id: Project.ID.make("test"), directory: AbsolutePath.make("/") },
}),
data: agentInfo(value),
})
: Effect.fail(new Error(`Agent not found: ${input.agentID}`)),
),
),
list: () => Effect.die("unused agent.list"),
reload: agent.reload,
transform: (callback) =>
@ -126,17 +132,17 @@ export function agentHost(agent: AgentV2.Interface): Plugin.Context["agent"] {
callback({
list: () => draft.list().map(agentInfo),
get: (id) => {
const value = draft.get(AgentV2.ID.make(id))
const value = draft.get(Agent.ID.make(id))
return value && agentInfo(value)
},
default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)),
default: (id) => draft.default(id === undefined ? undefined : Agent.ID.make(id)),
update: (id, update) =>
draft.update(AgentV2.ID.make(id), (value) => {
draft.update(Agent.ID.make(id), (value) => {
const current = agentInfo(value)
update(current)
Object.assign(value, current, { id: AgentV2.ID.make(current.id) })
Object.assign(value, current, { id: Agent.ID.make(current.id) })
}),
remove: (id) => draft.remove(AgentV2.ID.make(id)),
remove: (id) => draft.remove(Agent.ID.make(id)),
}),
),
}
@ -149,10 +155,6 @@ export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog
get: () => Effect.die("unused catalog.provider.get"),
},
model: {
get: (providerID, modelID) =>
catalog.model
.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID))
.pipe(Effect.map((value) => value && modelInfo(value))),
list: () => Effect.die("unused catalog.model.list"),
default: () => Effect.die("unused catalog.model.default"),
},
@ -167,7 +169,7 @@ export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog
models: new Map(Array.from(value.models, ([id, model]) => [id, modelInfo(model)])),
})),
get: (id) => {
const value = draft.provider.get(ProviderV2.ID.make(id))
const value = draft.provider.get(Provider.ID.make(id))
return (
value && {
provider: providerInfo(value.provider),
@ -176,41 +178,41 @@ export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog
)
},
update: (id, update) =>
draft.provider.update(ProviderV2.ID.make(id), (value) => {
draft.provider.update(Provider.ID.make(id), (value) => {
const current = providerInfo(value)
update(current)
Object.assign(value, current, { id: ProviderV2.ID.make(current.id) })
Object.assign(value, current, { id: Provider.ID.make(current.id) })
}),
remove: (id) => draft.provider.remove(ProviderV2.ID.make(id)),
remove: (id) => draft.provider.remove(Provider.ID.make(id)),
},
model: {
get: (providerID, modelID) => {
const value = draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID))
const value = draft.model.get(Provider.ID.make(providerID), Model.ID.make(modelID))
return value && modelInfo(value)
},
update: (providerID, modelID, update) =>
draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), (value) => {
draft.model.update(Provider.ID.make(providerID), Model.ID.make(modelID), (value) => {
const current = modelInfo(value)
update(current)
Object.assign(value, current, {
id: ModelV2.ID.make(current.id),
providerID: ProviderV2.ID.make(current.providerID),
family: current.family === undefined ? undefined : ModelV2.Family.make(current.family),
id: Model.ID.make(current.id),
providerID: Provider.ID.make(current.providerID),
family: current.family === undefined ? undefined : Model.Family.make(current.family),
variants: current.variants?.map((variant) => ({
...variant,
id: ModelV2.VariantID.make(variant.id),
id: Model.VariantID.make(variant.id),
})),
})
}),
remove: (providerID, modelID) =>
draft.model.remove(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
default: {
get: () => {
const value = draft.model.default.get()
return value && { providerID: value.providerID, modelID: value.modelID }
},
set: (providerID, modelID) =>
draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
draft.model.default.set(Provider.ID.make(providerID), Model.ID.make(modelID)),
},
},
}),
@ -370,7 +372,7 @@ export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["w
}
}
function oauthCredential(value: CredentialOAuth) {
function oauthCredential(value: Credential.OAuth) {
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
}
@ -390,7 +392,7 @@ function method(value: Integration.Method) {
}
function internalMethod(
value: IntegrationOAuthMethod | IntegrationCommandMethod | IntegrationKeyMethod | IntegrationEnvMethod,
value: IntegrationMethodRegistration["method"],
): Integration.Method {
if (value.type === "env") return value
if (value.type === "key") return value
@ -407,7 +409,7 @@ function internalMethod(
}
}
function agentInfo(value: AgentV2.Info) {
function agentInfo(value: Agent.Info) {
return {
...value,
model: value.model && { ...value.model },
@ -420,7 +422,7 @@ function agentInfo(value: AgentV2.Info) {
}
}
function providerInfo(value: ProviderV2.MutableInfo) {
function providerInfo(value: Provider.MutableInfo) {
return {
...value,
settings: value.settings && { ...value.settings },
@ -429,7 +431,7 @@ function providerInfo(value: ProviderV2.MutableInfo) {
}
}
function modelInfo(value: ModelV2.Info | ModelV2.MutableInfo) {
function modelInfo(value: Model.Info | Model.MutableInfo) {
return {
...value,
settings: value.settings && { ...value.settings },

View file

@ -6,12 +6,12 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { Model } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
@ -21,7 +21,7 @@ const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
)
const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, EventV2.node]), [
const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, Bus.node]), [
[Location.node, locationLayer],
])
const it = testEffect(layer)
@ -33,8 +33,8 @@ describe("ModelsDevPlugin", () => {
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("acme")
const modelID = ModelV2.ID.make("gpt-5.4")
const providerID = Provider.ID.make("acme")
const modelID = Model.ID.make("gpt-5.4")
const models = ModelsDev.Service.of({
get: () =>
Effect.succeed([
@ -42,7 +42,7 @@ describe("ModelsDevPlugin", () => {
info: {
id: providerID,
name: "Acme",
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://api.acme.test/v1" },
},
environment: [],
@ -52,7 +52,7 @@ describe("ModelsDevPlugin", () => {
modelID,
providerID,
name: "GPT-5.4",
family: ModelV2.Family.make("gpt"),
family: Model.Family.make("gpt"),
capabilities: { tools: true, input: [], output: [] },
variants: [],
time: { released: Date.parse("2026-01-01") },
@ -89,12 +89,12 @@ describe("ModelsDevPlugin", () => {
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
},
{
id: ModelV2.ID.make("gpt-5.4-fast"),
id: Model.ID.make("gpt-5.4-fast"),
modelID,
providerID,
name: "GPT-5.4 Fast",
family: ModelV2.Family.make("gpt"),
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
family: Model.Family.make("gpt"),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://api.acme.test/v1" },
headers: { "x-mode": "fast" },
body: { service_tier: "priority" },
@ -146,8 +146,8 @@ describe("ModelsDevPlugin", () => {
}),
).pipe(Effect.provideService(ModelsDev.Service, models))
const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4"))
const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast"))
const base = yield* catalog.model.get(providerID, Model.ID.make("gpt-5.4"))
const fast = yield* catalog.model.get(providerID, Model.ID.make("gpt-5.4-fast"))
expect(base?.variants).toEqual([])
expect(base?.body).toBeUndefined()
@ -156,7 +156,7 @@ describe("ModelsDevPlugin", () => {
modelID: "gpt-5.4",
providerID: "acme",
name: "GPT-5.4 Fast",
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://api.acme.test/v1" },
headers: { "x-mode": "fast" },
body: { service_tier: "priority" },
@ -231,13 +231,13 @@ describe("ModelsDevPlugin", () => {
}),
)
const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning"))
const model = yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-reasoning"))
expect(model?.variants?.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
Model.VariantID.make("low"),
Model.VariantID.make("high"),
])
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("low"),
id: Model.VariantID.make("low"),
settings: {
reasoningEffort: "low",
reasoningSummary: "auto",
@ -245,7 +245,7 @@ describe("ModelsDevPlugin", () => {
},
})
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
id: Model.VariantID.make("high"),
settings: {
reasoningEffort: "high",
reasoningSummary: "auto",
@ -253,7 +253,7 @@ describe("ModelsDevPlugin", () => {
},
})
const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high"))
const mode = yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-reasoning-high"))
expect(mode).toMatchObject({
id: "gpt-reasoning-high",
name: "GPT Reasoning High",
@ -261,229 +261,229 @@ describe("ModelsDevPlugin", () => {
body: { service_tier: "priority" },
})
expect(mode?.variants?.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
Model.VariantID.make("low"),
Model.VariantID.make("high"),
])
const pro = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-pro"))
const pro = yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-reasoning-pro"))
expect(pro).toMatchObject({
id: "gpt-reasoning-pro",
body: { reasoning: { mode: "pro" } },
})
const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget"))
const budgetModel = yield* catalog.model.get(Provider.ID.anthropic, Model.ID.make("claude-budget"))
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
id: Model.VariantID.make("high"),
settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
})
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("max"),
id: Model.VariantID.make("max"),
settings: { thinking: { type: "enabled", budgetTokens: 31999 } },
})
const anthropicEffortModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-opus-4.7"))
const anthropicEffortModel = yield* catalog.model.get(Provider.ID.anthropic, Model.ID.make("claude-opus-4.7"))
expect(anthropicEffortModel?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{ id: Model.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("low"),
id: Model.VariantID.make("low"),
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
},
])
const anthropicToggleModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-toggle"))
const anthropicToggleModel = yield* catalog.model.get(Provider.ID.anthropic, Model.ID.make("claude-toggle"))
expect(anthropicToggleModel?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{ id: Model.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
id: Model.VariantID.make("thinking"),
settings: { thinking: { type: "adaptive", display: "summarized" } },
},
])
const opus45 = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-opus-4-5"))
const opus45 = yield* catalog.model.get(Provider.ID.anthropic, Model.ID.make("claude-opus-4-5"))
expect(opus45?.variants).toEqual([
{ id: ModelV2.VariantID.make("low"), settings: { effort: "low" } },
{ id: ModelV2.VariantID.make("high"), settings: { effort: "high" } },
{ id: Model.VariantID.make("low"), settings: { effort: "low" } },
{ id: Model.VariantID.make("high"), settings: { effort: "high" } },
])
const grok = yield* catalog.model.get(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4.5"))
const grok = yield* catalog.model.get(Provider.ID.make("xai"), Model.ID.make("grok-4.5"))
expect(grok?.variants).toEqual(
["low", "medium", "high"].map((id) => ({
id: ModelV2.VariantID.make(id),
id: Model.VariantID.make(id),
settings: { reasoningEffort: id },
})),
)
const minimax = yield* catalog.model.get(ProviderV2.ID.make("opencode-go"), ModelV2.ID.make("minimax-m3"))
const minimax = yield* catalog.model.get(Provider.ID.make("opencode-go"), Model.ID.make("minimax-m3"))
expect(minimax?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{ id: Model.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
id: Model.VariantID.make("thinking"),
settings: { thinking: { type: "adaptive", display: "summarized" } },
},
])
const toggle = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-only"))
const toggle = yield* catalog.model.get(Provider.ID.make("alibaba"), Model.ID.make("toggle-only"))
expect(toggle?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{ id: ModelV2.VariantID.make("thinking"), settings: { enableThinking: true } },
{ id: Model.VariantID.make("none"), settings: { enableThinking: false } },
{ id: Model.VariantID.make("thinking"), settings: { enableThinking: true } },
])
const combined = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-budget"))
const combined = yield* catalog.model.get(Provider.ID.make("alibaba"), Model.ID.make("toggle-budget"))
expect(combined?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{ id: Model.VariantID.make("none"), settings: { enableThinking: false } },
{
id: ModelV2.VariantID.make("high"),
id: Model.VariantID.make("high"),
settings: { enableThinking: true, thinkingBudget: 8000 },
},
{
id: ModelV2.VariantID.make("max"),
id: Model.VariantID.make("max"),
settings: { enableThinking: true, thinkingBudget: 16000 },
},
])
const gateway = yield* catalog.model.get(ProviderV2.ID.make("vercel"), ModelV2.ID.make("alibaba/qwen-toggle"))
const gateway = yield* catalog.model.get(Provider.ID.make("vercel"), Model.ID.make("alibaba/qwen-toggle"))
expect(gateway?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{ id: Model.VariantID.make("none"), settings: { enableThinking: false } },
{
id: ModelV2.VariantID.make("high"),
id: Model.VariantID.make("high"),
settings: { enableThinking: true, thinkingBudget: 8000 },
},
{
id: ModelV2.VariantID.make("max"),
id: Model.VariantID.make("max"),
settings: { enableThinking: true, thinkingBudget: 16000 },
},
])
const gatewayNova = yield* catalog.model.get(ProviderV2.ID.make("vercel"), ModelV2.ID.make("amazon/nova-2-lite"))
const gatewayNova = yield* catalog.model.get(Provider.ID.make("vercel"), Model.ID.make("amazon/nova-2-lite"))
expect(gatewayNova?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
id: Model.VariantID.make("none"),
settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
id: Model.VariantID.make("low"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
id: Model.VariantID.make("high"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
},
])
const gatewayFallback = yield* catalog.model.get(
ProviderV2.ID.make("vercel"),
ModelV2.ID.make("deepseek/deepseek-toggle"),
Provider.ID.make("vercel"),
Model.ID.make("deepseek/deepseek-toggle"),
)
expect(gatewayFallback?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
id: Model.VariantID.make("none"),
settings: { reasoning: { enabled: false } },
},
{
id: ModelV2.VariantID.make("low"),
id: Model.VariantID.make("low"),
settings: { reasoningEffort: "low" },
},
{
id: ModelV2.VariantID.make("high"),
id: Model.VariantID.make("high"),
settings: { reasoningEffort: "high" },
},
])
const openrouter = yield* catalog.model.get(
ProviderV2.ID.make("openrouter"),
ModelV2.ID.make("openrouter-toggle"),
Provider.ID.make("openrouter"),
Model.ID.make("openrouter-toggle"),
)
expect(openrouter?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { reasoning: { enabled: false } } },
{ id: ModelV2.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } },
{ id: Model.VariantID.make("none"), settings: { reasoning: { enabled: false } } },
{ id: Model.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } },
])
const google = yield* catalog.model.get(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini-2.5-flash"))
const google = yield* catalog.model.get(Provider.ID.make("google"), Model.ID.make("gemini-2.5-flash"))
expect(google?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
id: Model.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("high"),
id: Model.VariantID.make("high"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
},
{
id: ModelV2.VariantID.make("max"),
id: Model.VariantID.make("max"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
},
])
const vertex = yield* catalog.model.get(
ProviderV2.ID.make("google-vertex"),
ModelV2.ID.make("gemini-2.5-flash-lite"),
Provider.ID.make("google-vertex"),
Model.ID.make("gemini-2.5-flash-lite"),
)
expect(vertex?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
id: Model.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("high"),
id: Model.VariantID.make("high"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
},
{
id: ModelV2.VariantID.make("max"),
id: Model.VariantID.make("max"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
},
])
const bedrock = yield* catalog.model.get(
ProviderV2.ID.make("amazon-bedrock"),
ModelV2.ID.make("amazon.nova-2-lite-v1:0"),
Provider.ID.make("amazon-bedrock"),
Model.ID.make("amazon.nova-2-lite-v1:0"),
)
expect(bedrock?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
id: Model.VariantID.make("none"),
settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
id: Model.VariantID.make("low"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
id: Model.VariantID.make("high"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
},
])
const sapGemini = yield* catalog.model.get(ProviderV2.ID.make("sap-ai-core"), ModelV2.ID.make("gemini-2.5-flash"))
const sapGemini = yield* catalog.model.get(Provider.ID.make("sap-ai-core"), Model.ID.make("gemini-2.5-flash"))
expect(sapGemini?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
id: Model.VariantID.make("none"),
settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } },
},
{
id: ModelV2.VariantID.make("high"),
id: Model.VariantID.make("high"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } } },
},
{
id: ModelV2.VariantID.make("max"),
id: Model.VariantID.make("max"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } },
},
])
const sapNova = yield* catalog.model.get(ProviderV2.ID.make("sap-ai-core"), ModelV2.ID.make("amazon--nova-lite"))
const sapNova = yield* catalog.model.get(Provider.ID.make("sap-ai-core"), Model.ID.make("amazon--nova-lite"))
expect(sapNova?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
id: Model.VariantID.make("none"),
settings: {
modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } },
},
},
{
id: ModelV2.VariantID.make("low"),
id: Model.VariantID.make("low"),
settings: {
modelParams: { additionalModelRequestFields: { output_config: { effort: "low" } } },
},
},
{
id: ModelV2.VariantID.make("high"),
id: Model.VariantID.make("high"),
settings: {
modelParams: { additionalModelRequestFields: { output_config: { effort: "high" } } },
},
@ -491,31 +491,31 @@ describe("ModelsDevPlugin", () => {
])
const sapCohere = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("cohere--command-a-reasoning"),
Provider.ID.make("sap-ai-core"),
Model.ID.make("cohere--command-a-reasoning"),
)
expect(sapCohere?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
id: Model.VariantID.make("none"),
settings: { modelParams: { thinking: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
id: Model.VariantID.make("low"),
settings: { modelParams: { reasoning_effort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
id: Model.VariantID.make("high"),
settings: { modelParams: { reasoning_effort: "high" } },
},
])
const sapAnthropicEffort = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("anthropic--claude-4.7-opus"),
Provider.ID.make("sap-ai-core"),
Model.ID.make("anthropic--claude-4.7-opus"),
)
expect(sapAnthropicEffort?.variants).toEqual([
{
id: ModelV2.VariantID.make("low"),
id: Model.VariantID.make("low"),
settings: {
modelParams: {
additionalModelRequestFields: {
@ -528,12 +528,12 @@ describe("ModelsDevPlugin", () => {
])
const sapAnthropicBudget = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("anthropic--claude-4-sonnet"),
Provider.ID.make("sap-ai-core"),
Model.ID.make("anthropic--claude-4-sonnet"),
)
expect(sapAnthropicBudget?.variants).toEqual([
{
id: ModelV2.VariantID.make("high"),
id: Model.VariantID.make("high"),
settings: {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 8000 } },
@ -541,7 +541,7 @@ describe("ModelsDevPlugin", () => {
},
},
{
id: ModelV2.VariantID.make("max"),
id: Model.VariantID.make("max"),
settings: {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 16000 } },

View file

@ -1,24 +1,21 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { DateTime, Effect, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { WebSearch } from "@opencode-ai/core/websearch"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Plugin } from "@opencode-ai/plugin/v2"
import { Tool } from "@opencode-ai/plugin/v2/tool"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Tool } from "@opencode-ai/core/tool"
import { Provider } from "@opencode-ai/core/provider"
import { define } from "@opencode-ai/plugin/promise/plugin"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
import { host as testHost } from "./host"
@ -35,7 +32,7 @@ describe("fromPromise", () => {
})
yield* PluginPromise.fromPromise(
Plugin.define({
define({
id: "promise-session-generate",
setup: async (ctx) => {
expect(await ctx.session.generate({ sessionID: "ses_generate", prompt: "Summarize" })).toEqual({
@ -67,7 +64,7 @@ describe("fromPromise", () => {
SessionPending.Synthetic.make({
admittedSeq: 1,
id: SessionMessage.ID.make(input.id),
sessionID: SessionV2.ID.make(input.sessionID),
sessionID: Session.ID.make(input.sessionID),
timeCreated: DateTime.makeUnsafe(0),
type: "synthetic",
data: {
@ -82,7 +79,7 @@ describe("fromPromise", () => {
})
yield* PluginPromise.fromPromise(
Plugin.define({
define({
id: "promise-session-synthetic",
setup: async (ctx) => {
await ctx.session.synthetic(input)
@ -101,10 +98,10 @@ describe("fromPromise", () => {
it.effect("forwards standard client reads", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const seen: string[] = []
const promisePlugin = Plugin.define({
const promisePlugin = define({
id: "promise-client-reads",
setup: async (ctx) => {
const results = await Promise.all([
@ -128,31 +125,36 @@ describe("fromPromise", () => {
}),
)
it.effect("forwards direct agent and model reads", () =>
it.effect("forwards direct agent and model list reads", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* agents.transform((draft) =>
draft.update(AgentV2.ID.make("reviewer"), (agent) => {
draft.update(Agent.ID.make("reviewer"), (agent) => {
agent.description = "Reviews code"
}),
)
yield* catalog.transform((draft) =>
draft.model.update(ProviderV2.ID.make("test"), ModelV2.ID.make("alias"), (model) => {
model.modelID = ModelV2.ID.make("gpt-5")
draft.model.update(Provider.ID.make("test"), Model.ID.make("alias"), (model) => {
model.modelID = Model.ID.make("gpt-5")
}),
)
yield* PluginPromise.fromPromise(
Plugin.define({
define({
id: "promise-direct-reads",
setup: async (ctx) => {
expect(await ctx.agent.get("reviewer")).toMatchObject({ description: "Reviews code" })
expect(await ctx.agent.get("missing")).toBeUndefined()
expect(await ctx.catalog.model.get("test", "alias")).toMatchObject({ modelID: "gpt-5" })
expect(await ctx.catalog.model.get("test", "missing")).toBeUndefined()
expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({
description: "Reviews code",
})
await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow("Agent not found: missing")
const models = (await ctx.catalog.model.list()).data
expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({
modelID: "gpt-5",
})
expect(models.find((model) => model.providerID === "test" && model.id === "missing")).toBeUndefined()
},
}),
).effect(host)
@ -161,11 +163,11 @@ describe("fromPromise", () => {
it.effect("loads a promise plugin and registers a transform hook", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const plugin = yield* PluginV2.Service
const agents = yield* Agent.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const promisePlugin = Plugin.define({
const promisePlugin = define({
id: "promise-example",
setup: async (ctx) => {
expect(ctx.options.mode).toBe("strict")
@ -181,7 +183,7 @@ describe("fromPromise", () => {
const adapted = PluginPromise.fromPromise(promisePlugin)
yield* adapted.effect({ ...host, options: { mode: "strict" } })
expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
description: "Reviews code",
mode: "subagent",
})
@ -190,11 +192,11 @@ describe("fromPromise", () => {
it.effect("forwards session context hooks", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise(
Plugin.define({
define({
id: "promise-session-context",
setup: async (ctx) => {
await ctx.session.hook("context", (event) => {
@ -205,8 +207,8 @@ describe("fromPromise", () => {
}),
).effect(host)
const event: SessionHooks["context"] = {
sessionID: SessionV2.ID.make("ses_promise_session_context"),
agent: AgentV2.ID.make("build"),
sessionID: Session.ID.make("ses_promise_session_context"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
system: [SystemPart.make("Initial")],
messages: [Message.user("Hello")],
@ -222,11 +224,11 @@ describe("fromPromise", () => {
it.effect("disposes a hook registration on request", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const plugin = yield* PluginV2.Service
const agents = yield* Agent.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const promisePlugin = Plugin.define({
const promisePlugin = define({
id: "promise-dispose",
setup: async (ctx) => {
const registration = await ctx.agent.transform((draft) => {
@ -241,16 +243,16 @@ describe("fromPromise", () => {
const adapted = PluginPromise.fromPromise(promisePlugin)
yield* adapted.effect(host)
expect(yield* agents.get(AgentV2.ID.make("temp"))).toBeUndefined()
expect(yield* agents.get(Agent.ID.make("temp"))).toBeUndefined()
}),
)
it.effect("registers a standalone web search provider", () =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const promisePlugin = Plugin.define({
const promisePlugin = define({
id: "promise-websearch",
setup: async (ctx) => {
await ctx.websearch.transform((draft) => {
@ -279,10 +281,10 @@ describe("fromPromise", () => {
it.effect("runs the setup cleanup when the plugin scope closes", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const events: string[] = []
const promisePlugin = Plugin.define({
const promisePlugin = define({
id: "promise-cleanup",
setup: async () => {
events.push("setup")
@ -306,17 +308,18 @@ describe("fromPromise", () => {
it.effect("constructs plain Promise tool definitions in the host", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const progress: ToolRegistry.Progress[] = []
const promisePlugin = Plugin.define({
const progress: Tool.Metadata[] = []
const promisePlugin = define({
id: "promise-tool",
setup: async (ctx) => {
await ctx.tool.transform((tools) => {
tools.add(
"hello",
Tool.make({
{
name: "hello",
options: { codemode: false },
description: "Hello",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
@ -324,8 +327,7 @@ describe("fromPromise", () => {
await context.progress({ phase: "greeting" })
return { output: `Hello, ${name}!` }
},
}),
{ codemode: false },
},
)
})
},
@ -337,8 +339,8 @@ describe("fromPromise", () => {
expect(toolSet.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" }))
expect(
yield* toolSet.execute({
sessionID: SessionV2.ID.make("ses_promise_tool"),
agent: AgentV2.ID.make("build"),
sessionID: Session.ID.make("ses_promise_tool"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_promise_tool"),
progress: (update) => Effect.sync(() => progress.push(update)),
call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },

View file

@ -3,18 +3,18 @@ import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* AmazonBedrockPlugin.effect(host)
@ -83,9 +83,9 @@ describe("AmazonBedrockPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const bedrock = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.amazonBedrock),
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock"),
const bedrock = Provider.Info.make({
...Provider.Info.empty(Provider.ID.amazonBedrock),
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
settings: { endpoint: "https://bedrock.example" },
})
catalog.provider.update(bedrock.id, (item) => {
@ -94,8 +94,8 @@ describe("AmazonBedrockPlugin", () => {
})
})
yield* addPlugin()
const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock))
expect(result.package).toBe(ProviderV2.aisdk("@ai-sdk/amazon-bedrock"))
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
expect(result.package).toBe(Provider.aisdk("@ai-sdk/amazon-bedrock"))
expect(result.settings).toEqual({ baseURL: "https://bedrock.example" })
}),
)
@ -103,14 +103,14 @@ describe("AmazonBedrockPlugin", () => {
it.effect("prefers endpoint over baseURL for SDK base URL", () =>
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/amazon-bedrock",
options: {
@ -129,14 +129,14 @@ describe("AmazonBedrockPlugin", () => {
it.effect("uses baseURL as SDK base URL", () =>
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/amazon-bedrock",
options: {
@ -164,14 +164,14 @@ describe("AmazonBedrockPlugin", () => {
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock" },
@ -185,14 +185,14 @@ describe("AmazonBedrockPlugin", () => {
it.effect("uses config region over AWS_REGION for SDK base URL", () =>
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock", region: "eu-west-1" },
@ -205,14 +205,14 @@ describe("AmazonBedrockPlugin", () => {
it.effect("uses AWS_REGION for SDK base URL when config region is absent", () =>
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock" },
@ -225,14 +225,14 @@ describe("AmazonBedrockPlugin", () => {
it.effect("defaults SDK region to us-east-1", () =>
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock" },
@ -245,15 +245,15 @@ describe("AmazonBedrockPlugin", () => {
it.effect("loads bearer token option into env and uses bearer auth", () =>
withEnv({ AWS_ACCESS_KEY_ID: undefined, AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const headers: Array<string | null> = []
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/amazon-bedrock",
options: {
@ -275,15 +275,15 @@ describe("AmazonBedrockPlugin", () => {
it.effect("prefers bearer token env over bearer token option", () =>
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "env-token" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const headers: Array<string | null> = []
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/amazon-bedrock",
options: {
@ -305,14 +305,14 @@ describe("AmazonBedrockPlugin", () => {
it.effect("creates Mantle SDK with GPT-5 OpenAI base path", () =>
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")),
modelID: ModelV2.ID.make("openai.gpt-5.5"),
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("openai.gpt-5.5")),
modelID: Model.ID.make("openai.gpt-5.5"),
package: Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"),
}),
package: "@ai-sdk/amazon-bedrock/mantle",
options: {
@ -332,24 +332,24 @@ describe("AmazonBedrockPlugin", () => {
it.effect("selects Mantle APIs without Bedrock cross-region prefixes", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")),
modelID: ModelV2.ID.make("openai.gpt-5.5"),
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("openai.gpt-5.5")),
modelID: Model.ID.make("openai.gpt-5.5"),
package: Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"),
}),
sdk: fakeSelectorSdk(calls),
options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" },
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")),
modelID: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"),
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("openai.gpt-oss-safeguard-120b")),
modelID: Model.ID.make("openai.gpt-oss-safeguard-120b"),
package: Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"),
}),
sdk: fakeSelectorSdk(calls),
options: { region: "us-east-1" },
@ -360,14 +360,14 @@ describe("AmazonBedrockPlugin", () => {
it.effect("ignores other Bedrock provider subpaths", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/anthropic"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("@ai-sdk/amazon-bedrock/anthropic"),
}),
package: "@ai-sdk/amazon-bedrock/anthropic",
options: { name: "amazon-bedrock" },
@ -387,15 +387,15 @@ describe("AmazonBedrockPlugin", () => {
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const headers: Array<string | null> = []
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/amazon-bedrock",
options: {
@ -419,51 +419,51 @@ describe("AmazonBedrockPlugin", () => {
it.effect("applies legacy cross-region inference prefixes", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "eu-west-1" },
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("global.anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("global.anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "eu-west-1" },
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "ap-northeast-1" },
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "ap-southeast-2" },
@ -481,15 +481,15 @@ describe("AmazonBedrockPlugin", () => {
it.effect("uses AWS_REGION for language prefixes when region option is absent", () =>
withEnv({ AWS_REGION: "eu-west-1" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make("anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
@ -501,7 +501,7 @@ describe("AmazonBedrockPlugin", () => {
it.effect("applies the full legacy cross-region prefix matrix", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
const cases = [
@ -573,10 +573,10 @@ describe("AmazonBedrockPlugin", () => {
yield* addPlugin()
for (const item of cases) {
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)),
modelID: ModelV2.ID.make(item.modelID),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.amazonBedrock, Model.ID.make(item.modelID)),
modelID: Model.ID.make(item.modelID),
package: Provider.aisdk("test-provider"),
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: item.region },
@ -588,15 +588,15 @@ describe("AmazonBedrockPlugin", () => {
it.effect("ignores non-Bedrock providers for language selection", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.openai, Model.ID.make("anthropic.claude-sonnet-4-5")),
modelID: Model.ID.make("anthropic.claude-sonnet-4-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "eu-west-1" },

View file

@ -2,18 +2,18 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* AnthropicPlugin.effect(host)
@ -29,9 +29,9 @@ describe("AnthropicPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const item = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.anthropic),
package: ProviderV2.aisdk("@ai-sdk/anthropic"),
const item = Provider.Info.make({
...Provider.Info.empty(Provider.ID.anthropic),
package: Provider.aisdk("@ai-sdk/anthropic"),
headers: { Existing: "1" },
})
catalog.provider.update(item.id, (draft) => {
@ -40,32 +40,32 @@ describe("AnthropicPlugin", () => {
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).headers?.["anthropic-beta"]).toBe(
expect(required(yield* catalog.provider.get(Provider.ID.anthropic)).headers?.["anthropic-beta"]).toBe(
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
)
expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).headers?.Existing).toBe("1")
expect(required(yield* catalog.provider.get(Provider.ID.anthropic)).headers?.Existing).toBe("1")
}),
)
it.effect("ignores non-Anthropic providers", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {}))
yield* catalog.transform((catalog) => catalog.provider.update(Provider.ID.openai, () => {}))
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).headers?.["anthropic-beta"]).toBeUndefined()
expect(required(yield* catalog.provider.get(Provider.ID.openai)).headers?.["anthropic-beta"]).toBeUndefined()
}),
)
it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")),
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
package: ProviderV2.aisdk("@ai-sdk/anthropic"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-anthropic"), Model.ID.make("claude-sonnet-4-5")),
modelID: Model.ID.make("claude-sonnet-4-5"),
package: Provider.aisdk("@ai-sdk/anthropic"),
}),
package: "@ai-sdk/anthropic",
options: { name: "custom-anthropic", apiKey: "test" },
@ -76,14 +76,14 @@ describe("AnthropicPlugin", () => {
it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")),
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
package: ProviderV2.aisdk("@ai-sdk/anthropic"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.anthropic, Model.ID.make("claude-sonnet-4-5")),
modelID: Model.ID.make("claude-sonnet-4-5"),
package: Provider.aisdk("@ai-sdk/anthropic"),
}),
package: "@ai-sdk/anthropic",
options: { name: "anthropic", apiKey: "test" },

View file

@ -3,18 +3,18 @@ import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* AzureCognitiveServicesPlugin.effect(host)
@ -65,12 +65,12 @@ describe("AzureCognitiveServicesPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
item.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.make("azure-cognitive-services"), (item) => {
item.package = Provider.aisdk("@ai-sdk/openai-compatible")
})
})
yield* addPlugin()
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
const result = required(yield* catalog.provider.get(Provider.ID.make("azure-cognitive-services")))
expect(result).toMatchObject({
package: "aisdk:@ai-sdk/openai-compatible",
settings: { baseURL: "https://cognitive.cognitiveservices.azure.com/openai" },
@ -85,12 +85,12 @@ describe("AzureCognitiveServicesPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const azure = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services")),
const azure = Provider.Info.make({
...Provider.Info.empty(Provider.ID.make("azure-cognitive-services")),
package: "aisdk:@ai-sdk/openai-compatible",
})
const openai = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.openai),
const openai = Provider.Info.make({
...Provider.Info.empty(Provider.ID.openai),
package: "aisdk:test-provider",
})
catalog.provider.update(azure.id, (item) => {
@ -103,8 +103,8 @@ describe("AzureCognitiveServicesPlugin", () => {
})
})
yield* addPlugin()
const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services")))
const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai))
const azure = required(yield* catalog.provider.get(Provider.ID.make("azure-cognitive-services")))
const openai = required(yield* catalog.provider.get(Provider.ID.openai))
expect(azure.settings?.baseURL).toBeUndefined()
expect(azure).toMatchObject({ package: "aisdk:@ai-sdk/openai-compatible" })
expect(openai.settings?.baseURL).toBeUndefined()
@ -115,14 +115,14 @@ describe("AzureCognitiveServicesPlugin", () => {
it.effect("selects chat only for completion URLs", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("azure-cognitive-services"), Model.ID.make("deployment")),
modelID: Model.ID.make("deployment"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),
@ -134,23 +134,23 @@ describe("AzureCognitiveServicesPlugin", () => {
it.effect("uses the legacy Azure selector order and provider guard", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("azure-cognitive-services"), Model.ID.make("deployment")),
modelID: Model.ID.make("deployment"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
const ignored = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.openai, Model.ID.make("deployment")),
modelID: Model.ID.make("deployment"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),
@ -163,33 +163,33 @@ describe("AzureCognitiveServicesPlugin", () => {
it.effect("falls back from responses to messages, chat, then languageModel", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
const sdk = fakeSelectorSdk(calls)
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("messages-deployment")),
modelID: ModelV2.ID.make("messages-deployment"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("azure-cognitive-services"), Model.ID.make("messages-deployment")),
modelID: Model.ID.make("messages-deployment"),
package: "aisdk:test-provider",
}),
sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel },
options: {},
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")),
modelID: ModelV2.ID.make("chat-deployment"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("azure-cognitive-services"), Model.ID.make("chat-deployment")),
modelID: Model.ID.make("chat-deployment"),
package: "aisdk:test-provider",
}),
sdk: { chat: sdk.chat, languageModel: sdk.languageModel },
options: {},
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("language-deployment")),
modelID: ModelV2.ID.make("language-deployment"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("azure-cognitive-services"), Model.ID.make("language-deployment")),
modelID: Model.ID.make("language-deployment"),
package: "aisdk:test-provider",
}),
sdk: { languageModel: sdk.languageModel },

View file

@ -3,18 +3,18 @@ import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* AzurePlugin.effect(host)
@ -65,12 +65,12 @@ describe("AzurePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.package = ProviderV2.aisdk("@ai-sdk/azure")
catalog.provider.update(Provider.ID.azure, (item) => {
item.package = Provider.aisdk("@ai-sdk/azure")
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).settings?.resourceName).toBe("from-env")
expect(required(yield* catalog.provider.get(Provider.ID.azure)).settings?.resourceName).toBe("from-env")
}),
),
)
@ -80,20 +80,20 @@ describe("AzurePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const azure = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.azure),
package: ProviderV2.aisdk("@ai-sdk/azure"),
const azure = Provider.Info.make({
...Provider.Info.empty(Provider.ID.azure),
package: Provider.aisdk("@ai-sdk/azure"),
settings: { resourceName: "from-config" },
})
catalog.provider.update(azure.id, (item) => {
item.package = azure.package
item.settings = { resourceName: "from-config" }
})
catalog.provider.update(ProviderV2.ID.openai, () => {})
catalog.provider.update(Provider.ID.openai, () => {})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).settings?.resourceName).toBe("from-config")
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).settings?.resourceName).toBeUndefined()
expect(required(yield* catalog.provider.get(Provider.ID.azure)).settings?.resourceName).toBe("from-config")
expect(required(yield* catalog.provider.get(Provider.ID.openai)).settings?.resourceName).toBeUndefined()
}),
),
)
@ -103,9 +103,9 @@ describe("AzurePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const azure = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.azure),
package: ProviderV2.aisdk("@ai-sdk/azure"),
const azure = Provider.Info.make({
...Provider.Info.empty(Provider.ID.azure),
package: Provider.aisdk("@ai-sdk/azure"),
settings: { resourceName: "" },
})
catalog.provider.update(azure.id, (item) => {
@ -114,7 +114,7 @@ describe("AzurePlugin", () => {
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).settings?.resourceName).toBe("from-env")
expect(required(yield* catalog.provider.get(Provider.ID.azure)).settings?.resourceName).toBe("from-env")
}),
),
)
@ -124,9 +124,9 @@ describe("AzurePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const azure = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.azure),
package: ProviderV2.aisdk("@ai-sdk/azure"),
const azure = Provider.Info.make({
...Provider.Info.empty(Provider.ID.azure),
package: Provider.aisdk("@ai-sdk/azure"),
settings: { resourceName: " " },
})
catalog.provider.update(azure.id, (item) => {
@ -135,7 +135,7 @@ describe("AzurePlugin", () => {
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).settings?.resourceName).toBe("from-env")
expect(required(yield* catalog.provider.get(Provider.ID.azure)).settings?.resourceName).toBe("from-env")
}),
),
)
@ -143,14 +143,14 @@ describe("AzurePlugin", () => {
it.effect("allows configured baseURL without resourceName", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.azure, Model.ID.make("deployment")),
modelID: Model.ID.make("deployment"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/azure",
options: { name: "azure", baseURL: "https://proxy.example.com/openai" },
@ -167,10 +167,10 @@ describe("AzurePlugin", () => {
yield* addPlugin()
const exit = yield* aisdk
.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.azure, Model.ID.make("deployment")),
modelID: Model.ID.make("deployment"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/azure",
options: { name: "azure" },
@ -183,15 +183,15 @@ describe("AzurePlugin", () => {
it.effect("selects chat only for completion URLs", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.azure, Model.ID.make("deployment")),
modelID: Model.ID.make("deployment"),
package: Provider.aisdk("test-provider"),
}),
sdk: fakeSelectorSdk(calls),
options: { useCompletionUrls: true },
@ -202,15 +202,15 @@ describe("AzurePlugin", () => {
it.effect("selects chat from per-call useCompletionUrls", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.azure, Model.ID.make("deployment")),
modelID: Model.ID.make("deployment"),
package: Provider.aisdk("test-provider"),
}),
sdk: fakeSelectorSdk(calls),
options: { useCompletionUrls: true },
@ -221,15 +221,15 @@ describe("AzurePlugin", () => {
it.effect("ignores model useCompletionUrls when per-call option is unset", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.azure, Model.ID.make("deployment")),
modelID: Model.ID.make("deployment"),
package: Provider.aisdk("test-provider"),
body: { useCompletionUrls: true },
}),
sdk: fakeSelectorSdk(calls),
@ -241,24 +241,24 @@ describe("AzurePlugin", () => {
it.effect("uses the legacy Azure selector order and provider guard", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.azure, Model.ID.make("deployment")),
modelID: Model.ID.make("deployment"),
package: Provider.aisdk("test-provider"),
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
const ignored = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.openai, Model.ID.make("deployment")),
modelID: Model.ID.make("deployment"),
package: Provider.aisdk("test-provider"),
}),
sdk: fakeSelectorSdk(calls),
options: {},
@ -270,7 +270,7 @@ describe("AzurePlugin", () => {
it.effect("falls back through the legacy Azure selector order", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
const make = (method: string) => (id: string) => {
@ -279,19 +279,19 @@ describe("AzurePlugin", () => {
}
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")),
modelID: ModelV2.ID.make("messages-deployment"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.azure, Model.ID.make("messages-deployment")),
modelID: Model.ID.make("messages-deployment"),
package: Provider.aisdk("test-provider"),
}),
sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") },
options: {},
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")),
modelID: ModelV2.ID.make("language-deployment"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.azure, Model.ID.make("language-deployment")),
modelID: Model.ID.make("language-deployment"),
package: Provider.aisdk("test-provider"),
}),
sdk: { languageModel: make("languageModel") },
options: {},

View file

@ -2,11 +2,11 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@ -14,7 +14,7 @@ const cerebrasOptions: Record<string, unknown>[] = []
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* CerebrasPlugin.effect(host)
@ -35,13 +35,13 @@ describe("CerebrasPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
item.package = ProviderV2.aisdk("@ai-sdk/cerebras")
catalog.provider.update(Provider.ID.make("cerebras"), (item) => {
item.package = Provider.aisdk("@ai-sdk/cerebras")
item.headers = { ...item.headers, Existing: "1" }
})
})
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras")))?.headers).toEqual({
expect((yield* catalog.provider.get(Provider.ID.make("cerebras")))?.headers).toEqual({
Existing: "1",
"X-Cerebras-3rd-Party-Integration": "opencode",
})
@ -51,25 +51,25 @@ describe("CerebrasPlugin", () => {
it.effect("ignores non-Cerebras providers", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
yield* catalog.transform((catalog) => catalog.provider.update(Provider.ID.make("groq"), () => {}))
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("groq")))?.headers).toBeUndefined()
expect((yield* catalog.provider.get(Provider.ID.make("groq")))?.headers).toBeUndefined()
}),
)
it.effect("creates a bundled Cerebras SDK with the model provider ID as the SDK name", () =>
Effect.gen(function* () {
cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(
ProviderV2.ID.make("custom-cerebras"),
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
model: Model.Info.make({
...Model.Info.default(
Provider.ID.make("custom-cerebras"),
Model.ID.make("llama-4-scout-17b-16e-instruct"),
),
modelID: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
modelID: Model.ID.make("llama-4-scout-17b-16e-instruct"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/cerebras",
@ -83,16 +83,16 @@ describe("CerebrasPlugin", () => {
it.effect("preserves an explicit bundled Cerebras SDK name option", () =>
Effect.gen(function* () {
cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(
ProviderV2.ID.make("custom-cerebras"),
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
model: Model.Info.make({
...Model.Info.default(
Provider.ID.make("custom-cerebras"),
Model.ID.make("llama-4-scout-17b-16e-instruct"),
),
modelID: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
modelID: Model.ID.make("llama-4-scout-17b-16e-instruct"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/cerebras",
@ -105,16 +105,16 @@ describe("CerebrasPlugin", () => {
it.effect("ignores non-Cerebras SDK packages", () =>
Effect.gen(function* () {
cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(
ProviderV2.ID.make("custom-cerebras"),
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
model: Model.Info.make({
...Model.Info.default(
Provider.ID.make("custom-cerebras"),
Model.ID.make("llama-4-scout-17b-16e-instruct"),
),
modelID: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
modelID: Model.ID.make("llama-4-scout-17b-16e-instruct"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/groq",

View file

@ -1,18 +1,18 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* CloudflareAIGatewayPlugin.effect(host)
@ -112,13 +112,13 @@ describe("CloudflareAIGatewayPlugin", () => {
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
package: "ai-gateway-provider",
@ -133,14 +133,14 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
package: "ai-gateway-provider",
@ -178,14 +178,14 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
package: "ai-gateway-provider",
@ -208,14 +208,14 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
package: "ai-gateway-provider",
@ -246,14 +246,14 @@ describe("CloudflareAIGatewayPlugin", () => {
() =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
package: "ai-gateway-provider",
@ -278,14 +278,14 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: "cf-aig-token" }), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
package: "ai-gateway-provider",
@ -301,14 +301,14 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
package: "ai-gateway-provider",
@ -325,14 +325,14 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: undefined }), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
package: "ai-gateway-provider",
@ -355,14 +355,14 @@ describe("CloudflareAIGatewayPlugin", () => {
() =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
package: "ai-gateway-provider",
@ -379,17 +379,17 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(
ProviderV2.ID.make("cloudflare-ai-gateway"),
ModelV2.ID.make("anthropic/claude-sonnet-4-5"),
model: Model.Info.make({
...Model.Info.default(
Provider.ID.make("cloudflare-ai-gateway"),
Model.ID.make("anthropic/claude-sonnet-4-5"),
),
modelID: ModelV2.ID.make("anthropic/claude-sonnet-4-5"),
modelID: Model.ID.make("anthropic/claude-sonnet-4-5"),
package: "aisdk:test-provider",
}),
package: "ai-gateway-provider",
@ -411,14 +411,14 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",

View file

@ -2,11 +2,11 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@ -14,7 +14,7 @@ import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* CloudflareWorkersAIPlugin.effect(host)
@ -82,20 +82,20 @@ describe("CloudflareWorkersAIPlugin", () => {
it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = ProviderV2.aisdk("test-provider")
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = Provider.aisdk("test-provider")
}),
)
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")))
const provider = required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))
const sdk = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
modelID: ModelV2.ID.make("@cf/model"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: provider.package,
settings: provider.settings,
}),
@ -116,13 +116,13 @@ describe("CloudflareWorkersAIPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = ProviderV2.aisdk("test-provider")
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = Provider.aisdk("test-provider")
provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" }
}),
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")))).toMatchObject({
expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
package: "aisdk:test-provider",
settings: { baseURL: "https://proxy.example/v1" },
})
@ -133,13 +133,13 @@ describe("CloudflareWorkersAIPlugin", () => {
it.effect("allows a configured baseURL without account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
modelID: ModelV2.ID.make("@cf/model"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: "aisdk:@ai-sdk/openai-compatible",
settings: { baseURL: "https://proxy.example/v1" },
}),
@ -156,13 +156,13 @@ describe("CloudflareWorkersAIPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = ProviderV2.aisdk("test-provider")
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = Provider.aisdk("test-provider")
provider.settings = { ...provider.settings, accountId: "configured-acct" }
}),
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")))).toMatchObject({
expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
package: "aisdk:test-provider",
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" },
})
@ -173,13 +173,13 @@ describe("CloudflareWorkersAIPlugin", () => {
it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
modelID: ModelV2.ID.make("@cf/model"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: "aisdk:@ai-sdk/openai-compatible",
settings: { baseURL: "https://proxy.example/v1" },
}),
@ -202,13 +202,13 @@ describe("CloudflareWorkersAIPlugin", () => {
it.effect("expands account ID vars in endpoint URLs", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
modelID: ModelV2.ID.make("@cf/model"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: "aisdk:@ai-sdk/openai-compatible",
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1" },
}),
@ -227,14 +227,14 @@ describe("CloudflareWorkersAIPlugin", () => {
it.effect("selects languageModel with the API model ID", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("@cf/api-model"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("alias")),
modelID: Model.ID.make("@cf/api-model"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),
@ -248,13 +248,13 @@ describe("CloudflareWorkersAIPlugin", () => {
it.effect("does not create an SDK for non OpenAI-compatible packages", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
modelID: ModelV2.ID.make("@cf/model"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: "aisdk:@ai-sdk/anthropic",
settings: { baseURL: "https://proxy.example/v1" },
}),

View file

@ -7,11 +7,11 @@ import path from "path"
import { fileURLToPath } from "url"
import { AISDK } from "@opencode-ai/core/aisdk"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@ -29,7 +29,7 @@ function npmEntrypoint(entrypoint?: string) {
}
const addPlugin = Effect.fn(function* (npm?: Npm.Interface) {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* DynamicProviderPlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm ?? (yield* Npm.Service)))
})
@ -52,10 +52,10 @@ describe("DynamicProviderPlugin", () => {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
modelID: ModelV2.ID.make("test-model"),
package: ProviderV2.aisdk(fixtureProvider),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom"), Model.ID.make("test-model")),
modelID: Model.ID.make("test-model"),
package: Provider.aisdk(fixtureProvider),
}),
package: fixtureProvider,
options: { name: "custom", marker: "dynamic" },
@ -71,10 +71,10 @@ describe("DynamicProviderPlugin", () => {
const sdk = { marker: "existing" }
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
modelID: ModelV2.ID.make("test-model"),
package: ProviderV2.aisdk(fixtureProvider),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom"), Model.ID.make("test-model")),
modelID: Model.ID.make("test-model"),
package: Provider.aisdk(fixtureProvider),
}),
package: fixtureProvider,
options: { name: "custom", marker: "dynamic" },
@ -89,10 +89,10 @@ describe("DynamicProviderPlugin", () => {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")),
modelID: ModelV2.ID.make("test-model"),
package: ProviderV2.aisdk(fixtureProvider),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-provider"), Model.ID.make("test-model")),
modelID: Model.ID.make("test-model"),
package: Provider.aisdk(fixtureProvider),
}),
package: fixtureProvider,
options: { name: "custom-provider", marker: "dynamic" },
@ -106,9 +106,9 @@ describe("DynamicProviderPlugin", () => {
const aisdk = yield* AISDK.Service
yield* addPlugin(npmEntrypoint(fixtureProviderPath))
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")),
modelID: ModelV2.ID.make("test-model"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("npm-provider"), Model.ID.make("test-model")),
modelID: Model.ID.make("test-model"),
package: "aisdk:fixture-provider",
}),
package: "fixture-provider",
@ -124,9 +124,9 @@ describe("DynamicProviderPlugin", () => {
yield* addPlugin(npmEntrypoint())
const exit = yield* aisdk
.language(
ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("alias"),
Model.Info.make({
...Model.Info.default(Provider.ID.make("missing-entrypoint"), Model.ID.make("alias")),
modelID: Model.ID.make("alias"),
package: "aisdk:fixture-provider",
}),
)
@ -142,9 +142,9 @@ describe("DynamicProviderPlugin", () => {
yield* addPlugin()
const exit = yield* aisdk
.language(
ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("alias"),
Model.Info.make({
...Model.Info.default(Provider.ID.make("bad-import"), Model.ID.make("alias")),
modelID: Model.ID.make("alias"),
package: "aisdk:file:///missing/provider-factory.js",
}),
)
@ -156,15 +156,15 @@ describe("DynamicProviderPlugin", () => {
itWithAISDK.live("wraps missing provider factory exports as AISDK init errors", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
yield* addPlugin(npmEntrypoint(tmp.entrypoint))
const exit = yield* aisdk
.language(
ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("alias"),
Model.Info.make({
...Model.Info.default(Provider.ID.make("missing-factory"), Model.ID.make("alias")),
modelID: Model.ID.make("alias"),
package: "aisdk:fixture-provider",
}),
)
@ -176,14 +176,14 @@ describe("DynamicProviderPlugin", () => {
itWithAISDK.effect("uses the model modelID for the default language model", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const language = yield* aisdk.language(
ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("test-model-api"),
package: ProviderV2.aisdk(fixtureProvider),
Model.Info.make({
...Model.Info.default(Provider.ID.make("custom"), Model.ID.make("alias")),
modelID: Model.ID.make("test-model-api"),
package: Provider.aisdk(fixtureProvider),
}),
)
expect(language).toMatchObject({ modelID: "test-model-api", options: { name: "custom" } })

View file

@ -1,8 +1,8 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { AISDK } from "@opencode-ai/core/aisdk"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba"
import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere"
@ -13,11 +13,11 @@ import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral"
import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity"
import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai"
import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const modelID = ModelV2.ID.make("test-model")
const modelID = Model.ID.make("test-model")
const options = { name: "custom-provider", apiKey: "test", baseURL: "https://example.test" }
const providers = [
{ id: "alibaba", plugin: AlibabaPlugin, package: "@ai-sdk/alibaba", provider: "alibaba.chat" },
@ -36,14 +36,14 @@ const it = testEffect(PluginTestLayer)
providers.forEach((item) =>
it.effect(`${item.id} loads only its exact package`, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* item.plugin.effect(host)
const model = ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make(item.id), modelID),
const model = Model.Info.make({
...Model.Info.default(Provider.ID.make(item.id), modelID),
modelID,
package: ProviderV2.aisdk(item.package),
package: Provider.aisdk(item.package),
})
const matched = yield* aisdk.runSDK({ model, package: item.package, options })
const ignored = yield* aisdk.runSDK({ model, package: `${item.package}/unsupported`, options })

View file

@ -3,11 +3,11 @@ import { App } from "@opencode-ai/core/app"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { copilotBaseURL, copilotFetch, GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { Integration } from "@opencode-ai/core/integration"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { testEffect } from "../lib/effect"
@ -16,7 +16,7 @@ import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* GithubCopilotPlugin.effect(host)
@ -94,22 +94,22 @@ describe("GithubCopilotPlugin", () => {
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const ignored = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
options: { name: "github-copilot" },
})
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/github-copilot",
@ -122,14 +122,14 @@ describe("GithubCopilotPlugin", () => {
it.effect("selects languageModel when responses and chat are absent", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")),
modelID: ModelV2.ID.make("claude-sonnet-4"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("claude-sonnet-4")),
modelID: Model.ID.make("claude-sonnet-4"),
package: "aisdk:test-provider",
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
@ -141,14 +141,14 @@ describe("GithubCopilotPlugin", () => {
it.effect("selects languageModel with the API model ID when responses and chat are absent", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("claude-sonnet-4"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("alias")),
modelID: Model.ID.make("claude-sonnet-4"),
package: "aisdk:test-provider",
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
@ -160,50 +160,50 @@ describe("GithubCopilotPlugin", () => {
it.effect("uses responses for gpt-5 models except gpt-5-mini", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")),
modelID: ModelV2.ID.make("gpt-5.1-codex"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5.1-codex")),
modelID: Model.ID.make("gpt-5.1-codex"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")),
modelID: ModelV2.ID.make("gpt-4o"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("gpt-4o")),
modelID: Model.ID.make("gpt-4o"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")),
modelID: ModelV2.ID.make("gpt-5-mini"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5-mini")),
modelID: Model.ID.make("gpt-5-mini"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")),
modelID: ModelV2.ID.make("gpt-5-mini-2025-08-07"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5-mini-2025-08-07")),
modelID: Model.ID.make("gpt-5-mini-2025-08-07"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),
@ -221,14 +221,14 @@ describe("GithubCopilotPlugin", () => {
it.effect("uses advertised Copilot endpoint metadata before model ID fallbacks", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("mai-code-1-flash-picker")),
modelID: ModelV2.ID.make("mai-code-1-flash-picker"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("mai-code-1-flash-picker")),
modelID: Model.ID.make("mai-code-1-flash-picker"),
package: "aisdk:test-provider",
settings: { endpoint: "responses" },
}),
@ -236,9 +236,9 @@ describe("GithubCopilotPlugin", () => {
options: { endpoint: "responses" },
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: "aisdk:test-provider",
settings: { endpoint: "chat" },
}),
@ -251,32 +251,32 @@ describe("GithubCopilotPlugin", () => {
it.effect("uses the API model ID when selecting responses or chat", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")),
modelID: ModelV2.ID.make("gpt-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("default")),
modelID: Model.ID.make("gpt-5"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")),
modelID: ModelV2.ID.make("gpt-5-mini"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("small")),
modelID: Model.ID.make("gpt-5-mini"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")),
modelID: ModelV2.ID.make("claude-sonnet-4"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("sonnet")),
modelID: Model.ID.make("claude-sonnet-4"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),
@ -290,12 +290,12 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.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"), () => {})
catalog.provider.update(Provider.ID.make("github-copilot"), () => {})
catalog.model.update(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5-chat-latest"), () => {})
})
yield* addPlugin()
expect(
required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest")))
required(yield* catalog.model.get(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5-chat-latest")))
.enabled,
).toBe(false)
}),
@ -305,12 +305,12 @@ describe("GithubCopilotPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.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"), () => {})
catalog.provider.update(Provider.ID.make("custom-copilot"), () => {})
catalog.model.update(Provider.ID.make("custom-copilot"), Model.ID.make("gpt-5-chat-latest"), () => {})
})
yield* addPlugin()
expect(
required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest")))
required(yield* catalog.model.get(Provider.ID.make("custom-copilot"), Model.ID.make("gpt-5-chat-latest")))
.enabled,
).toBe(true)
}),
@ -318,14 +318,14 @@ describe("GithubCopilotPlugin", () => {
it.effect("ignores non-Copilot providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("openai"), Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),

View file

@ -2,11 +2,11 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@ -14,7 +14,7 @@ const gitlabSDKOptions: Record<string, unknown>[] = []
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* GitLabPlugin.effect(host)
@ -64,13 +64,13 @@ describe("GitLabPlugin", () => {
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
modelID: ModelV2.ID.make("claude"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("gitlab"), Model.ID.make("claude")),
modelID: Model.ID.make("claude"),
package: "aisdk:test-provider",
}),
package: "gitlab-ai-provider",
@ -102,13 +102,13 @@ describe("GitLabPlugin", () => {
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
modelID: ModelV2.ID.make("claude"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("gitlab"), Model.ID.make("claude")),
modelID: Model.ID.make("claude"),
package: "aisdk:test-provider",
}),
package: "gitlab-ai-provider",
@ -128,13 +128,13 @@ describe("GitLabPlugin", () => {
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
modelID: ModelV2.ID.make("claude"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("gitlab"), Model.ID.make("claude")),
modelID: Model.ID.make("claude"),
package: "aisdk:test-provider",
}),
package: "gitlab-ai-provider",
@ -170,13 +170,13 @@ describe("GitLabPlugin", () => {
it.effect("ignores non-GitLab SDK packages", () =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
modelID: ModelV2.ID.make("claude"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("gitlab"), Model.ID.make("claude")),
modelID: Model.ID.make("claude"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai",
@ -189,14 +189,14 @@ describe("GitLabPlugin", () => {
it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: [string, unknown][] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
modelID: ModelV2.ID.make("duo-workflow-custom"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("gitlab"), Model.ID.make("duo-workflow-custom")),
modelID: Model.ID.make("duo-workflow-custom"),
package: "aisdk:test-provider",
headers: {},
settings: { workflowRef: "ref", workflowDefinition: "definition" },
@ -223,14 +223,14 @@ describe("GitLabPlugin", () => {
it.effect("uses exact static workflow model ids when the provider recognizes them", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: [string, unknown][] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")),
modelID: ModelV2.ID.make("duo-workflow-exact"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("gitlab"), Model.ID.make("duo-workflow-exact")),
modelID: Model.ID.make("duo-workflow-exact"),
package: "aisdk:test-provider",
}),
sdk: {
@ -251,14 +251,14 @@ describe("GitLabPlugin", () => {
it.effect("uses provider feature flags instead of model settings feature flags", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: [string, unknown][] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
modelID: ModelV2.ID.make("duo-workflow-custom"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("gitlab"), Model.ID.make("duo-workflow-custom")),
modelID: Model.ID.make("duo-workflow-custom"),
package: "aisdk:test-provider",
headers: {},
settings: { featureFlags: { request_flag: true } },
@ -278,14 +278,14 @@ describe("GitLabPlugin", () => {
it.effect("uses agenticChat with provider aiGatewayHeaders and feature flags for normal models", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: [string, unknown][] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
modelID: ModelV2.ID.make("claude"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("gitlab"), Model.ID.make("claude")),
modelID: Model.ID.make("claude"),
package: "aisdk:test-provider",
headers: { h: "v" },
settings: {},

View file

@ -3,18 +3,18 @@ import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* (definition: typeof GoogleVertexAnthropicPlugin | typeof GoogleVertexPlugin) {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* definition.effect(host)
@ -63,15 +63,15 @@ describe("GoogleVertexAnthropicPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/google-vertex/anthropic")
catalog.provider.update(Provider.ID.make("google-vertex-anthropic"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/google-vertex/anthropic")
}),
)
yield* addPlugin(GoogleVertexAnthropicPlugin)
expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.settings?.project).toBe(
expect((yield* catalog.provider.get(Provider.ID.make("google-vertex-anthropic")))?.settings?.project).toBe(
"cloud-project",
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.settings?.location).toBe(
expect((yield* catalog.provider.get(Provider.ID.make("google-vertex-anthropic")))?.settings?.location).toBe(
"cloud-location",
)
}),
@ -83,16 +83,16 @@ describe("GoogleVertexAnthropicPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/google-vertex/anthropic")
catalog.provider.update(Provider.ID.make("google-vertex-anthropic"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/google-vertex/anthropic")
provider.settings = { ...provider.settings, project: "configured-project", location: "configured-location" }
}),
)
yield* addPlugin(GoogleVertexAnthropicPlugin)
expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.settings?.project).toBe(
expect((yield* catalog.provider.get(Provider.ID.make("google-vertex-anthropic")))?.settings?.project).toBe(
"configured-project",
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.settings?.location).toBe(
expect((yield* catalog.provider.get(Provider.ID.make("google-vertex-anthropic")))?.settings?.location).toBe(
"configured-location",
)
}),
@ -111,16 +111,16 @@ describe("GoogleVertexAnthropicPlugin", () => {
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(
ProviderV2.ID.make("google-vertex-anthropic"),
ModelV2.ID.make("claude-sonnet-4-5"),
model: Model.Info.make({
...Model.Info.default(
Provider.ID.make("google-vertex-anthropic"),
Model.ID.make("claude-sonnet-4-5"),
),
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
modelID: Model.ID.make("claude-sonnet-4-5"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/google-vertex/anthropic",
@ -138,16 +138,16 @@ describe("GoogleVertexAnthropicPlugin", () => {
{ GOOGLE_CLOUD_PROJECT: "project", GOOGLE_CLOUD_LOCATION: "cloud-location", VERTEX_LOCATION: "vertex-location" },
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(
ProviderV2.ID.make("google-vertex-anthropic"),
ModelV2.ID.make("claude-sonnet-4-5"),
model: Model.Info.make({
...Model.Info.default(
Provider.ID.make("google-vertex-anthropic"),
Model.ID.make("claude-sonnet-4-5"),
),
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
modelID: Model.ID.make("claude-sonnet-4-5"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/google-vertex/anthropic",
@ -162,13 +162,13 @@ describe("GoogleVertexAnthropicPlugin", () => {
it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("claude-sonnet-4-5")),
modelID: Model.ID.make("claude-sonnet-4-5"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/google-vertex/anthropic",
@ -182,13 +182,13 @@ describe("GoogleVertexAnthropicPlugin", () => {
it.effect("keeps configured baseURL for google-vertex Anthropic models", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("claude-sonnet-4-5")),
modelID: Model.ID.make("claude-sonnet-4-5"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/google-vertex/anthropic",
@ -198,25 +198,25 @@ describe("GoogleVertexAnthropicPlugin", () => {
}),
)
it.effect("selects google-vertex Anthropic language models through V2 plugins", () =>
it.effect("selects google-vertex Anthropic language models through plugins", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin(GoogleVertexPlugin)
yield* addPlugin(GoogleVertexAnthropicPlugin)
const sdkResult = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
modelID: ModelV2.ID.make(" claude-sonnet-4-5 "),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make(" claude-sonnet-4-5 ")),
modelID: Model.ID.make(" claude-sonnet-4-5 "),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex", project: "project", location: "us" },
})
const languageResult = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
modelID: ModelV2.ID.make(" claude-sonnet-4-5 "),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make(" claude-sonnet-4-5 ")),
modelID: Model.ID.make(" claude-sonnet-4-5 "),
package: "aisdk:test-provider",
}),
sdk: sdkResult.sdk,
@ -232,14 +232,14 @@ describe("GoogleVertexAnthropicPlugin", () => {
it.effect("trims model IDs before selecting language models", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin(GoogleVertexAnthropicPlugin)
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
modelID: ModelV2.ID.make(" claude-sonnet-4-5 "),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("google-vertex-anthropic"), Model.ID.make(" claude-sonnet-4-5 ")),
modelID: Model.ID.make(" claude-sonnet-4-5 "),
package: "aisdk:test-provider",
}),
sdk: { languageModel: selector(calls) },
@ -251,14 +251,14 @@ describe("GoogleVertexAnthropicPlugin", () => {
it.effect("ignores non Vertex Anthropic providers for language selection", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("claude-sonnet-4-5")),
modelID: Model.ID.make("claude-sonnet-4-5"),
package: "aisdk:test-provider",
}),
sdk: { languageModel: selector(calls) },

View file

@ -2,11 +2,11 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@ -16,7 +16,7 @@ const googleAuthOptions: Record<string, any>[] = []
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* GoogleVertexPlugin.effect(host)
@ -91,14 +91,14 @@ describe("GoogleVertexPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.opencode, (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.opencode, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { ...provider.settings, baseURL: "https://opencode.ai/zen/v1" }
}),
)
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode))
const provider = required(yield* catalog.provider.get(Provider.ID.opencode))
expect(provider.settings).toEqual({ baseURL: "https://opencode.ai/zen/v1" })
}),
)
@ -117,8 +117,8 @@ describe("GoogleVertexPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.make("google-vertex"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = {
...provider.settings,
baseURL:
@ -127,7 +127,7 @@ describe("GoogleVertexPlugin", () => {
}),
)
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
const provider = required(yield* catalog.provider.get(Provider.ID.make("google-vertex")))
expect(provider.settings?.project).toBe("google-cloud-project")
expect(provider.settings?.location).toBe("google-vertex-location")
expect(provider).toMatchObject({
@ -155,12 +155,12 @@ describe("GoogleVertexPlugin", () => {
() =>
Effect.gen(function* () {
vertexOptions.length = 0
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.make("google-vertex"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = {
...provider.settings,
baseURL:
@ -169,11 +169,11 @@ describe("GoogleVertexPlugin", () => {
}),
)
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
const provider = required(yield* catalog.provider.get(Provider.ID.make("google-vertex")))
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
modelID: ModelV2.ID.make("gemini"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("gemini")),
modelID: Model.ID.make("gemini"),
package: "aisdk:@ai-sdk/google-vertex",
}),
package: "@ai-sdk/google-vertex",
@ -208,8 +208,8 @@ describe("GoogleVertexPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.make("google-vertex"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = {
...provider.settings,
baseURL:
@ -219,7 +219,7 @@ describe("GoogleVertexPlugin", () => {
}),
)
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
const provider = required(yield* catalog.provider.get(Provider.ID.make("google-vertex")))
expect(provider.settings?.project).toBe("config-project")
expect(provider.settings?.location).toBe("global")
expect(provider).toMatchObject({
@ -234,8 +234,8 @@ describe("GoogleVertexPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.make("google-vertex"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = {
...provider.settings,
baseURL:
@ -245,7 +245,7 @@ describe("GoogleVertexPlugin", () => {
}),
)
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
const provider = required(yield* catalog.provider.get(Provider.ID.make("google-vertex")))
expect(provider).toMatchObject({
package: "aisdk:@ai-sdk/openai-compatible",
settings: { baseURL: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu" },
@ -267,13 +267,13 @@ describe("GoogleVertexPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/google-vertex")
catalog.provider.update(Provider.ID.make("google-vertex"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/google-vertex")
provider.settings = { ...provider.settings, project: "config-project" }
}),
)
yield* addPlugin()
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
const provider = required(yield* catalog.provider.get(Provider.ID.make("google-vertex")))
expect(provider.settings?.project).toBe("config-project")
expect(provider.settings?.location).toBe("us-central1")
}),
@ -289,13 +289,13 @@ describe("GoogleVertexPlugin", () => {
() =>
Effect.gen(function* () {
vertexOptions.length = 0
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
modelID: ModelV2.ID.make("gemini"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("gemini")),
modelID: Model.ID.make("gemini"),
package: "aisdk:@ai-sdk/google-vertex",
}),
package: "@ai-sdk/google-vertex",
@ -313,7 +313,7 @@ describe("GoogleVertexPlugin", () => {
Effect.gen(function* () {
googleAuthOptions.length = 0
const fetchCalls: { input: Parameters<typeof fetch>[0]; init?: RequestInit }[] = []
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.hook.sdk((evt) =>
@ -338,9 +338,9 @@ describe("GoogleVertexPlugin", () => {
Effect.void,
() =>
aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
modelID: ModelV2.ID.make("gemini"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("gemini")),
modelID: Model.ID.make("gemini"),
package: "aisdk:@ai-sdk/openai-compatible",
}),
package: "@ai-sdk/openai-compatible",
@ -361,14 +361,14 @@ describe("GoogleVertexPlugin", () => {
it.effect("trims model IDs before selecting language models", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")),
modelID: ModelV2.ID.make(" gemini-2.5-pro "),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make(" gemini-2.5-pro ")),
modelID: Model.ID.make(" gemini-2.5-pro "),
package: "aisdk:test-provider",
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },

View file

@ -1,18 +1,18 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* GooglePlugin.effect(host)
@ -21,13 +21,13 @@ const addPlugin = Effect.fn(function* () {
describe("GooglePlugin", () => {
it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")),
modelID: ModelV2.ID.make("gemini"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-google"), Model.ID.make("gemini")),
modelID: Model.ID.make("gemini"),
package: "aisdk:@ai-sdk/google",
}),
package: "@ai-sdk/google",
@ -40,13 +40,13 @@ describe("GooglePlugin", () => {
it.effect("ignores non-Google SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")),
modelID: ModelV2.ID.make("gemini"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("google"), Model.ID.make("gemini")),
modelID: Model.ID.make("gemini"),
package: "aisdk:@ai-sdk/google",
}),
package: "@ai-sdk/google-vertex",
@ -58,13 +58,13 @@ describe("GooglePlugin", () => {
it.effect("uses default languageModel loading with provider ID parity", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdkEvent = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("gemini-api"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-google"), Model.ID.make("alias")),
modelID: Model.ID.make("gemini-api"),
package: "aisdk:@ai-sdk/google",
}),
package: "@ai-sdk/google",
@ -87,9 +87,9 @@ describe("GooglePlugin", () => {
yield* addPlugin()
const resolved = yield* aisdk.model(
ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("gemini-api"),
Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-google"), Model.ID.make("alias")),
modelID: Model.ID.make("gemini-api"),
package: "aisdk:@ai-sdk/google",
settings: { apiKey: "test" },
}),

View file

@ -1,18 +1,18 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* KiloPlugin.effect(host)
})
@ -26,20 +26,20 @@ describe("KiloPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.make("kilo"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" }
provider.headers = { Existing: "value" }
})
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
catalog.provider.update(Provider.ID.openrouter, () => {})
})
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).toEqual({
expect((yield* catalog.provider.get(Provider.ID.make("kilo")))?.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.headers).toBeUndefined()
expect((yield* catalog.provider.get(Provider.ID.openrouter))?.headers).toBeUndefined()
}),
)
@ -47,20 +47,20 @@ describe("KiloPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.make("kilo"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" }
})
})
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).toEqual({
expect((yield* catalog.provider.get(Provider.ID.make("kilo")))?.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).not.toHaveProperty("http-referer")
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).not.toHaveProperty("x-title")
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).not.toHaveProperty("X-Source")
expect((yield* catalog.provider.get(Provider.ID.make("kilo")))?.headers).not.toHaveProperty("http-referer")
expect((yield* catalog.provider.get(Provider.ID.make("kilo")))?.headers).not.toHaveProperty("x-title")
expect((yield* catalog.provider.get(Provider.ID.make("kilo")))?.headers).not.toHaveProperty("X-Source")
}),
)
@ -68,18 +68,18 @@ describe("KiloPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => {
provider.package = ProviderV2.aisdk("kilo")
catalog.provider.update(Provider.ID.make("kilo"), (provider) => {
provider.package = Provider.aisdk("kilo")
})
catalog.provider.update(ProviderV2.ID.make("custom-kilo"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.make("custom-kilo"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" }
})
})
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).toBeUndefined()
expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo")))?.headers).toEqual({
expect((yield* catalog.provider.get(Provider.ID.make("kilo")))?.headers).toBeUndefined()
expect((yield* catalog.provider.get(Provider.ID.make("custom-kilo")))?.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})

View file

@ -2,18 +2,18 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const integration = yield* Integration.Service
yield* LLMGatewayPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration))
@ -33,21 +33,21 @@ describe("LLMGatewayPlugin", () => {
editor.update(Integration.ID.make("openrouter"), () => {})
})
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.make("llmgateway"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://api.llmgateway.io/v1" }
provider.headers = { Existing: "value" }
})
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
catalog.provider.update(Provider.ID.openrouter, () => {})
})
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.headers).toEqual({
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-Source": "opencode",
})
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.headers).toBeUndefined()
expect((yield* catalog.provider.get(Provider.ID.openrouter))?.headers).toBeUndefined()
}),
)
@ -59,16 +59,16 @@ describe("LLMGatewayPlugin", () => {
editor.update(Integration.ID.make("llmgateway"), () => {})
})
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => {
catalog.provider.update(Provider.ID.make("llmgateway"), (provider) => {
provider.disabled = true
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://api.llmgateway.io/v1" }
})
})
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.disabled).toBe(true)
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.headers).toBeUndefined()
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.disabled).toBe(true)
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.headers).toBeUndefined()
}),
)
})

View file

@ -1,18 +1,18 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* NvidiaPlugin.effect(host)
})
@ -26,21 +26,21 @@ describe("NvidiaPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.make("nvidia"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://integrate.api.nvidia.com/v1" }
provider.headers = { Existing: "value" }
})
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
catalog.provider.update(Provider.ID.openrouter, () => {})
})
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.headers).toEqual({
expect((yield* catalog.provider.get(Provider.ID.make("nvidia")))?.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
})
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.headers).toBeUndefined()
expect((yield* catalog.provider.get(Provider.ID.openrouter))?.headers).toBeUndefined()
}),
)
@ -48,14 +48,14 @@ describe("NvidiaPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.make("nvidia"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://integrate.api.nvidia.com/v1" }
})
})
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.headers).toEqual({
expect((yield* catalog.provider.get(Provider.ID.make("nvidia")))?.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
@ -67,15 +67,15 @@ describe("NvidiaPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.make("nvidia"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://integrate.api.nvidia.com/v1" }
provider.headers = { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }
})
})
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.headers).toEqual({
expect((yield* catalog.provider.get(Provider.ID.make("nvidia")))?.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin",

View file

@ -1,18 +1,18 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* OpenAICompatiblePlugin.effect(host)
@ -21,22 +21,22 @@ const addPlugin = Effect.fn(function* () {
describe("OpenAICompatiblePlugin", () => {
it.effect("preserves explicit includeUsage false and defaults it to true", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const defaulted = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom"), Model.ID.make("model")),
modelID: Model.ID.make("model"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
options: { name: "custom" },
})
const disabled = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom"), Model.ID.make("model")),
modelID: Model.ID.make("model"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
@ -49,13 +49,13 @@ describe("OpenAICompatiblePlugin", () => {
it.effect("defaults includeUsage for OpenAI-compatible package matches", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom"), Model.ID.make("model")),
modelID: Model.ID.make("model"),
package: "aisdk:test-provider",
}),
package: "file:///tmp/@ai-sdk/openai-compatible-provider.js",
@ -67,7 +67,7 @@ describe("OpenAICompatiblePlugin", () => {
it.effect("uses the provider ID as the OpenAI-compatible provider name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const observed: string[] = []
yield* addPlugin()
@ -77,9 +77,9 @@ describe("OpenAICompatiblePlugin", () => {
}),
)
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-provider"), Model.ID.make("model")),
modelID: Model.ID.make("model"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
@ -98,9 +98,9 @@ describe("OpenAICompatiblePlugin", () => {
})
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("model")),
modelID: Model.ID.make("model"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",

View file

@ -6,18 +6,18 @@ import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
const integrations = yield* Integration.Service
@ -63,14 +63,14 @@ describe("OpenAIPlugin", () => {
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-openai"), Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/openai",
options: { name: "custom-openai", apiKey: "test" },
@ -81,14 +81,14 @@ describe("OpenAIPlugin", () => {
it.effect("ignores non-OpenAI SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/openai-compatible",
options: { name: "openai" },
@ -99,15 +99,15 @@ describe("OpenAIPlugin", () => {
it.effect("uses the Responses API for language models", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("gpt-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.openai, Model.ID.make("alias")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: fakeSelectorSdk(calls),
options: {},
@ -119,15 +119,15 @@ describe("OpenAIPlugin", () => {
it.effect("ignores non-OpenAI providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.anthropic, Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: fakeSelectorSdk(calls),
options: {},
@ -141,20 +141,20 @@ describe("OpenAIPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const item = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.openai),
package: ProviderV2.aisdk("@ai-sdk/openai"),
const item = Provider.Info.make({
...Provider.Info.empty(Provider.ID.openai),
package: Provider.aisdk("@ai-sdk/openai"),
})
catalog.provider.update(item.id, (draft) => {
draft.package = item.package
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5-chat-latest"), () => {})
})
yield* addPlugin()
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(true)
expect(
required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled,
required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5-chat-latest"))).enabled,
).toBe(false)
}),
)
@ -164,14 +164,14 @@ describe("OpenAIPlugin", () => {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
yield* catalog.transform((catalog) => {
const item = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.openai),
package: ProviderV2.aisdk("@ai-sdk/openai"),
const item = Provider.Info.make({
...Provider.Info.empty(Provider.ID.openai),
package: Provider.aisdk("@ai-sdk/openai"),
})
catalog.provider.update(item.id, (draft) => {
draft.package = item.package
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => {
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), (model) => {
model.cost = [
{
input: Money.USDPerMillionTokens.make(1),
@ -183,14 +183,14 @@ describe("OpenAIPlugin", () => {
},
]
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5-pro"), () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.4-pro"), (model) => {
model.modelID = ModelV2.ID.make("gpt-5.4")
catalog.model.update(item.id, Model.ID.make("gpt-5.5-pro"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5.4-pro"), (model) => {
model.modelID = Model.ID.make("gpt-5.4")
model.body = { reasoning: { mode: "pro" } }
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.6"), () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.6-sol"), () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5.6"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5.6-sol"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
})
yield* credentials.create({
integrationID: Integration.ID.make("openai"),
@ -205,20 +205,20 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const eligible = required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5")))
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.cost).toEqual([])
expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5-pro"))).enabled).toBe(
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
false,
)
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.4-pro"))).enabled).toBe(
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe(
false,
)
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.6"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.6-sol"))).enabled).toBe(
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"))).enabled).toBe(
true,
)
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
}),
)
@ -227,15 +227,15 @@ describe("OpenAIPlugin", () => {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
yield* catalog.transform((catalog) => {
const item = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.openai),
package: ProviderV2.aisdk("@ai-sdk/openai"),
const item = Provider.Info.make({
...Provider.Info.empty(Provider.ID.openai),
package: Provider.aisdk("@ai-sdk/openai"),
})
catalog.provider.update(item.id, (draft) => {
draft.package = item.package
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
})
yield* credentials.create({
integrationID: Integration.ID.make("openai"),
@ -243,8 +243,8 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5"))).enabled).toBe(true)
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))).enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
@ -252,18 +252,18 @@ describe("OpenAIPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const item = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.make("custom-openai")),
package: ProviderV2.aisdk("test-provider"),
const item = Provider.Info.make({
...Provider.Info.empty(Provider.ID.make("custom-openai")),
package: Provider.aisdk("test-provider"),
})
catalog.provider.update(item.id, (draft) => {
draft.package = item.package
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5-chat-latest"), () => {})
})
yield* addPlugin()
expect(
required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest")))
required(yield* catalog.model.get(Provider.ID.make("custom-openai"), Model.ID.make("gpt-5-chat-latest")))
.enabled,
).toBe(true)
}),

View file

@ -3,25 +3,25 @@ import { Money } from "@opencode-ai/schema/money"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const integration = yield* Integration.Service
yield* OpencodePlugin.effect(host).pipe(
Effect.provideService(EventV2.Service, events),
Effect.provideService(Bus.Service, bus),
Effect.provideService(Integration.Service, integration),
)
})
@ -215,18 +215,18 @@ describe("OpencodePlugin", () => {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) => {
draft.provider.update(ProviderV2.ID.make("remote"), () => {})
draft.model.update(ProviderV2.ID.make("remote"), ModelV2.ID.make("model"), (model) => {
draft.provider.update(Provider.ID.make("remote"), () => {})
draft.model.update(Provider.ID.make("remote"), Model.ID.make("model"), (model) => {
model.variants = [
{
id: ModelV2.VariantID.make("custom"),
id: Model.VariantID.make("custom"),
settings: {},
headers: { "x-custom": "true" },
body: { custom: true },
},
]
})
draft.model.update(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"), () => {})
draft.model.update(Provider.ID.make("remote"), Model.ID.make("stale"), () => {})
})
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
@ -240,44 +240,44 @@ describe("OpencodePlugin", () => {
yield* addPlugin()
expect(authorization).toEqual(["Bearer secret"])
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("remote")))
const provider = required(yield* catalog.provider.get(Provider.ID.make("remote")))
expect(provider).toMatchObject({
name: "Remote",
integrationID: "opencode",
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: `${server.url.origin}/v1`, custom: "value" },
headers: { "x-org-id": "org" },
})
expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
const model = required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model")))
const model = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("model")))
expect(model).toMatchObject({
name: "Remote Model",
family: "remote",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }],
limit: { context: 1000, output: 100 },
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 },
headers: { "x-org-id": "org" },
})
expect(model.variants).toEqual([
{
id: ModelV2.VariantID.make("custom"),
id: Model.VariantID.make("custom"),
settings: {},
headers: { "x-custom": "true" },
body: { custom: true },
},
{
id: ModelV2.VariantID.make("high"),
id: Model.VariantID.make("high"),
settings: { temperature: 0.2 },
headers: {},
},
])
expect(
required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("disabled"))).enabled,
required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("disabled"))).enabled,
).toBe(false)
expect(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"))).toBeDefined()
expect(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("stale"))).toBeDefined()
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
@ -288,14 +288,14 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
package: ProviderV2.aisdk("test-provider"),
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
})
const model = ModelV2.Info.make({
...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")),
modelID: ModelV2.ID.make("paid"),
package: ProviderV2.aisdk("test-provider"),
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("paid")),
modelID: Model.ID.make("paid"),
package: Provider.aisdk("test-provider"),
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
@ -304,8 +304,8 @@ describe("OpencodePlugin", () => {
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBe("public")
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBe("public")
expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("paid"))).enabled).toBe(false)
}),
),
)
@ -315,14 +315,14 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
package: ProviderV2.aisdk("test-provider"),
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
})
const model = ModelV2.Info.make({
...ModelV2.Info.default(provider.id, ModelV2.ID.make("free")),
modelID: ModelV2.ID.make("free"),
package: ProviderV2.aisdk("test-provider"),
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("free")),
modelID: Model.ID.make("free"),
package: Provider.aisdk("test-provider"),
cost: cost(0),
})
catalog.provider.update(provider.id, () => {})
@ -331,8 +331,8 @@ describe("OpencodePlugin", () => {
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBe("public")
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBe("public")
expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("free"))).enabled).toBe(true)
}),
),
)
@ -342,14 +342,14 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
package: ProviderV2.aisdk("test-provider"),
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
})
const model = ModelV2.Info.make({
...ModelV2.Info.default(provider.id, ModelV2.ID.make("output-only")),
modelID: ModelV2.ID.make("output-only"),
package: ProviderV2.aisdk("test-provider"),
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("output-only")),
modelID: Model.ID.make("output-only"),
package: Provider.aisdk("test-provider"),
cost: cost(0, 1),
})
catalog.provider.update(provider.id, () => {})
@ -358,8 +358,8 @@ describe("OpencodePlugin", () => {
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBe("public")
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBe("public")
expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("output-only"))).enabled).toBe(
true,
)
}),
@ -371,14 +371,14 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
package: ProviderV2.aisdk("test-provider"),
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
})
const model = ModelV2.Info.make({
...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")),
modelID: ModelV2.ID.make("paid"),
package: ProviderV2.aisdk("test-provider"),
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("paid")),
modelID: Model.ID.make("paid"),
package: Provider.aisdk("test-provider"),
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
@ -387,8 +387,8 @@ describe("OpencodePlugin", () => {
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBeUndefined()
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBeUndefined()
expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("paid"))).enabled).toBe(true)
}),
),
)
@ -405,14 +405,14 @@ describe("OpencodePlugin", () => {
})
})
yield* catalog.transform((catalog) => {
const provider = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
package: ProviderV2.aisdk("test-provider"),
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
})
const model = ModelV2.Info.make({
...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")),
modelID: ModelV2.ID.make("paid"),
package: ProviderV2.aisdk("test-provider"),
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("paid")),
modelID: Model.ID.make("paid"),
package: Provider.aisdk("test-provider"),
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
@ -421,8 +421,8 @@ describe("OpencodePlugin", () => {
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBeUndefined()
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBeUndefined()
expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("paid"))).enabled).toBe(true)
}),
),
)
@ -432,15 +432,15 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
package: ProviderV2.aisdk("test-provider"),
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
settings: { apiKey: "configured" },
})
const model = ModelV2.Info.make({
...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")),
modelID: ModelV2.ID.make("paid"),
package: ProviderV2.aisdk("test-provider"),
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("paid")),
modelID: Model.ID.make("paid"),
package: Provider.aisdk("test-provider"),
cost: cost(1),
})
catalog.provider.update(provider.id, (draft) => {
@ -452,8 +452,8 @@ describe("OpencodePlugin", () => {
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBe("configured")
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBe("configured")
expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("paid"))).enabled).toBe(true)
}),
),
)
@ -463,14 +463,14 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.openai),
package: ProviderV2.aisdk("test-provider"),
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.openai),
package: Provider.aisdk("test-provider"),
})
const model = ModelV2.Info.make({
...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")),
modelID: ModelV2.ID.make("paid"),
package: ProviderV2.aisdk("test-provider"),
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("paid")),
modelID: Model.ID.make("paid"),
package: Provider.aisdk("test-provider"),
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
@ -479,8 +479,8 @@ describe("OpencodePlugin", () => {
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).settings?.apiKey).toBeUndefined()
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
expect(required(yield* catalog.provider.get(Provider.ID.openai)).settings?.apiKey).toBeUndefined()
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("paid"))).enabled).toBe(true)
}),
),
)
@ -488,17 +488,17 @@ describe("OpencodePlugin", () => {
it.effect("prefers gpt-5-nano as the opencode small model", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.opencode
const providerID = Provider.ID.opencode
yield* catalog.transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
catalog.model.update(providerID, Model.ID.make("cheap-mini"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [...cost(1, 1)]
model.time.released = Date.now()
})
catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
catalog.model.update(providerID, Model.ID.make("gpt-5-nano"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [...cost(10, 10)]
@ -508,7 +508,7 @@ describe("OpencodePlugin", () => {
const selected = yield* catalog.model.small(providerID)
expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
expect(selected?.id).toBe(Model.ID.make("gpt-5-nano"))
}),
)
})

View file

@ -2,19 +2,19 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* OpenRouterPlugin.effect(host)
@ -29,34 +29,34 @@ describe("OpenRouterPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
provider.package = ProviderV2.aisdk("@openrouter/ai-sdk-provider")
catalog.provider.update(Provider.ID.openrouter, (provider) => {
provider.package = Provider.aisdk("@openrouter/ai-sdk-provider")
provider.headers = { Existing: "value" }
})
catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
catalog.provider.update(Provider.ID.make("nvidia"), () => {})
})
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.headers).toEqual({
expect((yield* catalog.provider.get(Provider.ID.openrouter))?.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.headers).toBeUndefined()
expect((yield* catalog.provider.get(Provider.ID.make("nvidia")))?.headers).toBeUndefined()
}),
)
it.effect("creates an SDK only for the OpenRouter package", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const ignored = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.openrouter, Model.ID.make("openai/gpt-5")),
modelID: Model.ID.make("openai/gpt-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/openai-compatible",
options: { name: "openrouter" },
@ -64,10 +64,10 @@ describe("OpenRouterPlugin", () => {
expect(ignored.sdk).toBeUndefined()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
package: ProviderV2.aisdk("test-provider"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom"), Model.ID.make("openai/gpt-5")),
modelID: Model.ID.make("openai/gpt-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@openrouter/ai-sdk-provider",
options: { name: "custom" },
@ -80,21 +80,21 @@ describe("OpenRouterPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
provider.package = ProviderV2.aisdk("@openrouter/ai-sdk-provider")
catalog.provider.update(Provider.ID.openrouter, (provider) => {
provider.package = Provider.aisdk("@openrouter/ai-sdk-provider")
})
catalog.provider.update(ProviderV2.ID.openai, () => {})
catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat"), () => {})
catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5"), () => {})
catalog.model.update(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"), () => {})
catalog.provider.update(Provider.ID.openai, () => {})
catalog.model.update(Provider.ID.openrouter, Model.ID.make("openai/gpt-5-chat"), () => {})
catalog.model.update(Provider.ID.openrouter, Model.ID.make("openai/gpt-5"), () => {})
catalog.model.update(Provider.ID.openai, Model.ID.make("openai/gpt-5-chat"), () => {})
})
yield* addPlugin()
expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe(
expect((yield* catalog.model.get(Provider.ID.openrouter, Model.ID.make("openai/gpt-5-chat")))?.enabled).toBe(
false,
)
expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")))?.enabled).toBe(true)
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe(true)
expect((yield* catalog.model.get(Provider.ID.openrouter, Model.ID.make("openai/gpt-5")))?.enabled).toBe(true)
expect((yield* catalog.model.get(Provider.ID.openai, Model.ID.make("openai/gpt-5-chat")))?.enabled).toBe(true)
}),
)
@ -102,12 +102,12 @@ describe("OpenRouterPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.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"), () => {})
catalog.provider.update(Provider.ID.make("custom-openrouter"), () => {})
catalog.model.update(Provider.ID.make("custom-openrouter"), Model.ID.make("gpt-5-chat-latest"), () => {})
})
yield* addPlugin()
expect(
(yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest")))
(yield* catalog.model.get(Provider.ID.make("custom-openrouter"), Model.ID.make("gpt-5-chat-latest")))
?.enabled,
).toBe(true)
}),

View file

@ -1,12 +1,12 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { Npm } from "@opencode-ai/util/npm"
import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@ -19,7 +19,7 @@ const npm = Npm.Service.of({
})
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* SapAICorePlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm))
@ -47,10 +47,10 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
}
function model(providerID: string) {
return ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")),
modelID: ModelV2.ID.make("sap-model"),
package: ProviderV2.aisdk(fixtureProvider),
return Model.Info.make({
...Model.Info.default(Provider.ID.make(providerID), Model.ID.make("sap-model")),
modelID: Model.ID.make("sap-model"),
package: Provider.aisdk(fixtureProvider),
})
}
@ -60,7 +60,7 @@ describe("SapAICorePlugin", () => {
{ AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" },
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdk = yield* aisdk.runSDK({
@ -83,7 +83,7 @@ describe("SapAICorePlugin", () => {
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdk = yield* aisdk.runSDK({
@ -102,7 +102,7 @@ describe("SapAICorePlugin", () => {
{ AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" },
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdk = yield* aisdk.runSDK({
@ -118,7 +118,7 @@ describe("SapAICorePlugin", () => {
it.effect("uses the callable SDK for language selection", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), {
@ -136,7 +136,7 @@ describe("SapAICorePlugin", () => {
{ AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" },
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdk = yield* aisdk.runSDK({

View file

@ -1,19 +1,19 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect, it as bun_it } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* SnowflakeCortexPlugin.effect(host)
@ -53,13 +53,13 @@ describe("SnowflakeCortexPlugin", () => {
it.effect("ignores non-snowflake-cortex providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")),
modelID: ModelV2.ID.make("gpt-4"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("openai"), Model.ID.make("gpt-4")),
modelID: Model.ID.make("gpt-4"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai",
@ -72,13 +72,13 @@ describe("SnowflakeCortexPlugin", () => {
it.effect("creates SDK for snowflake-cortex using SNOWFLAKE_CORTEX_PAT env var", () =>
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("snowflake-cortex"), Model.ID.make("claude-sonnet-4-6")),
modelID: Model.ID.make("claude-sonnet-4-6"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
@ -92,13 +92,13 @@ describe("SnowflakeCortexPlugin", () => {
it.effect("falls back to options.apiKey when SNOWFLAKE_CORTEX_PAT env var is absent", () =>
withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("snowflake-cortex"), Model.ID.make("claude-sonnet-4-6")),
modelID: Model.ID.make("claude-sonnet-4-6"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
@ -116,13 +116,13 @@ describe("SnowflakeCortexPlugin", () => {
it.effect("uses SNOWFLAKE_CORTEX_TOKEN env var", () =>
withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("snowflake-cortex"), Model.ID.make("claude-sonnet-4-6")),
modelID: Model.ID.make("claude-sonnet-4-6"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
@ -136,13 +136,13 @@ describe("SnowflakeCortexPlugin", () => {
it.effect("falls back to options.token when no Snowflake env token is set", () =>
withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("snowflake-cortex"), Model.ID.make("claude-sonnet-4-6")),
modelID: Model.ID.make("claude-sonnet-4-6"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
@ -160,13 +160,13 @@ describe("SnowflakeCortexPlugin", () => {
it.effect("sets includeUsage on the SDK options", () =>
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("snowflake-cortex"), Model.ID.make("claude-sonnet-4-6")),
modelID: Model.ID.make("claude-sonnet-4-6"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",

View file

@ -2,18 +2,18 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* VercelPlugin.effect(host)
@ -24,13 +24,13 @@ describe("VercelPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/vercel")
catalog.provider.update(Provider.ID.make("vercel"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/vercel")
provider.headers = { ...provider.headers, Existing: "1" }
})
})
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.headers).toEqual({
expect((yield* catalog.provider.get(Provider.ID.make("vercel")))?.headers).toEqual({
Existing: "1",
"http-referer": "https://opencode.ai/",
"x-title": "opencode",
@ -42,25 +42,25 @@ describe("VercelPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/vercel")
catalog.provider.update(Provider.ID.make("vercel"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/vercel")
}),
)
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.headers).not.toHaveProperty("HTTP-Referer")
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.headers).not.toHaveProperty("X-Title")
expect((yield* catalog.provider.get(Provider.ID.make("vercel")))?.headers).not.toHaveProperty("HTTP-Referer")
expect((yield* catalog.provider.get(Provider.ID.make("vercel")))?.headers).not.toHaveProperty("X-Title")
}),
)
it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const event = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")),
modelID: ModelV2.ID.make("v0-1.0-md"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-vercel"), Model.ID.make("v0-1.0-md")),
modelID: Model.ID.make("v0-1.0-md"),
package: "aisdk:@ai-sdk/vercel",
}),
package: "@ai-sdk/vercel",
@ -74,9 +74,9 @@ describe("VercelPlugin", () => {
it.effect("ignores non-Vercel providers", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gateway"), () => {}))
yield* catalog.transform((catalog) => catalog.provider.update(Provider.ID.make("gateway"), () => {}))
yield* addPlugin()
expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway")))?.headers).toBeUndefined()
expect((yield* catalog.provider.get(Provider.ID.make("gateway")))?.headers).toBeUndefined()
}),
)
})

View file

@ -3,18 +3,18 @@ import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* XAIPlugin.effect(host)
})
@ -61,9 +61,9 @@ describe("XAIPlugin", () => {
yield* addPlugin()
const ignored = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
modelID: ModelV2.ID.make("grok-4"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("xai"), Model.ID.make("grok-4")),
modelID: Model.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
package: "@ai-sdk/openai-compatible",
@ -71,9 +71,9 @@ describe("XAIPlugin", () => {
})
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
modelID: ModelV2.ID.make("grok-4"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("xai"), Model.ID.make("grok-4")),
modelID: Model.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
package: "@ai-sdk/xai",
@ -91,9 +91,9 @@ describe("XAIPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")),
modelID: ModelV2.ID.make("grok-4"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-xai"), Model.ID.make("grok-4")),
modelID: Model.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
package: "@ai-sdk/xai",
@ -111,9 +111,9 @@ describe("XAIPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("grok-4"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("xai"), Model.ID.make("alias")),
modelID: Model.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
sdk: fakeSelectorSdk(calls),
@ -132,9 +132,9 @@ describe("XAIPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")),
modelID: ModelV2.ID.make("grok-4"),
model: Model.Info.make({
...Model.Info.default(Provider.ID.openai, Model.ID.make("grok-4")),
modelID: Model.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
sdk: fakeSelectorSdk(calls),

View file

@ -1,18 +1,18 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* ZenmuxPlugin.effect(host)
})
@ -31,13 +31,13 @@ describe("ZenmuxPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.make("zenmux"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { ...provider.settings, baseURL: "https://zenmux.ai/api/v1" }
})
})
yield* addPlugin()
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux")))
const result = required(yield* catalog.provider.get(Provider.ID.make("zenmux")))
expect(result.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
expect(Object.keys(required(result.headers)).sort()).toEqual(["HTTP-Referer", "X-Title"])
}),
@ -47,15 +47,15 @@ describe("ZenmuxPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.make("zenmux"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { ...provider.settings, baseURL: "https://zenmux.ai/api/v1" }
provider.headers = { ...provider.headers, Existing: "value" }
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).headers).toEqual({
expect(required(yield* catalog.provider.get(Provider.ID.make("zenmux"))).headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
@ -67,15 +67,15 @@ describe("ZenmuxPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.make("zenmux"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { ...provider.settings, baseURL: "https://zenmux.ai/api/v1" }
provider.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).headers).toEqual({
expect(required(yield* catalog.provider.get(Provider.ID.make("zenmux"))).headers).toEqual({
"HTTP-Referer": "https://example.com/",
"X-Title": "custom-title",
})
@ -86,13 +86,13 @@ describe("ZenmuxPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
catalog.provider.update(Provider.ID.openrouter, (provider) => {
provider.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).headers).toEqual({
expect(required(yield* catalog.provider.get(Provider.ID.openrouter)).headers).toEqual({
"HTTP-Referer": "https://example.com/",
"X-Title": "custom-title",
})

View file

@ -7,17 +7,17 @@ import { Location } from "@opencode-ai/core/location"
import { Effect } from "effect"
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SkillV2 } from "@opencode-ai/core/skill"
import { Skill } from "@opencode-ai/core/skill"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "./host"
const it = testEffect(AppNodeBuilder.build(SkillV2.node))
const it = testEffect(AppNodeBuilder.build(Skill.node))
describe("SkillPlugin.Plugin", () => {
it.effect("registers built-in skills", () =>
Effect.gen(function* () {
const skill = yield* SkillV2.Service
const skill = yield* Skill.Service
yield* SkillPlugin.Plugin.effect(
host({
app: { name: "test", version: "1.2.3", channel: "beta" },

View file

@ -1,13 +1,13 @@
import { describe, expect, test } from "bun:test"
import { SystemPart } from "@opencode-ai/ai"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { SessionV2 } from "@opencode-ai/core/session"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { Session } from "@opencode-ai/core/session"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Effect } from "effect"
@ -19,13 +19,15 @@ import PROMPT_DEFAULT from "../../src/session/runner/prompt/base.txt"
const it = testEffect(PluginTestLayer)
const fallback = PROMPT_DEFAULT
const makeHost = Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
yield* agents.transform((draft) => draft.update(Agent.ID.make("build"), () => {}))
return yield* PluginHost.make(plugins)
})
const context = (id: string, system = fallback): SessionHooks["context"] => ({
sessionID: SessionV2.ID.make("ses_system_prompt"),
agent: AgentV2.ID.make("build"),
sessionID: Session.ID.make("ses_system_prompt"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make(id) }),
system: [SystemPart.make(system)],
messages: [],
@ -33,7 +35,7 @@ const context = (id: string, system = fallback): SessionHooks["context"] => ({
})
describe("SystemPromptPlugin", () => {
test("uses V2 vocabulary in the Meta prompt", () => {
test("uses current vocabulary in the Meta prompt", () => {
expect(PROMPT_META).toContain("webfetch tool")
expect(PROMPT_META).toContain("subagent tool")
expect(PROMPT_META).toContain("shell tool")
@ -92,10 +94,10 @@ describe("SystemPromptPlugin", () => {
it.effect("preserves an explicit agent system prompt", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const agents = yield* Agent.Service
const hooks = yield* PluginHooks.Service
yield* agents.transform((draft) =>
draft.update(AgentV2.ID.make("build"), (agent) => {
draft.update(Agent.ID.make("build"), (agent) => {
agent.system = "Custom agent prompt"
}),
)
@ -111,6 +113,21 @@ describe("SystemPromptPlugin", () => {
}),
)
it.effect("skips the hook when agent lookup fails", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
yield* SystemPromptPlugin.OpenAIPlugin.effect(pluginHost)
yield* agents.transform((draft) => draft.remove(Agent.ID.make("build")))
const event = context("gpt-5")
yield* hooks.trigger("session", "context", event)
expect(event.system[0]?.text).toBe(fallback)
}),
)
it.effect("allows one model-lab prompt plugin to be enabled independently", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service

View file

@ -3,9 +3,9 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { Model } from "@opencode-ai/core/model"
import { VariantPlugin } from "@opencode-ai/core/plugin/variant"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Layer } from "effect"
import { location } from "../fixture/location"
@ -23,17 +23,17 @@ describe("VariantPlugin", () => {
Effect.gen(function* () {
const service = yield* Catalog.Service
yield* service.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.opencode, (provider) => {
provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.provider.update(Provider.ID.opencode, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
})
catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2"), (model) => {
model.modelID = ModelV2.ID.make("glm-5.2")
model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
catalog.model.update(Provider.ID.opencode, Model.ID.make("glm-5.2"), (model) => {
model.modelID = Model.ID.make("glm-5.2")
model.package = Provider.aisdk("@ai-sdk/openai-compatible")
})
})
yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) }))
expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2")))?.variants).toEqual([
expect((yield* service.model.get(Provider.ID.opencode, Model.ID.make("glm-5.2")))?.variants).toEqual([
expect.objectContaining({ id: "high", settings: { reasoningEffort: "high" } }),
expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
])
@ -44,15 +44,15 @@ describe("VariantPlugin", () => {
Effect.gen(function* () {
const service = yield* Catalog.Service
yield* service.transform((catalog) => {
catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2"), (model) => {
model.modelID = ModelV2.ID.make("glm-5.2")
model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
model.variants = [{ id: ModelV2.VariantID.make("high"), settings: {}, headers: { custom: "true" }, body: {} }]
catalog.model.update(Provider.ID.opencode, Model.ID.make("glm-5.2"), (model) => {
model.modelID = Model.ID.make("glm-5.2")
model.package = Provider.aisdk("@ai-sdk/openai-compatible")
model.variants = [{ id: Model.VariantID.make("high"), settings: {}, headers: { custom: "true" }, body: {} }]
})
})
yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) }))
expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2")))?.variants).toEqual([
expect((yield* service.model.get(Provider.ID.opencode, Model.ID.make("glm-5.2")))?.variants).toEqual([
expect.objectContaining({ id: "high", headers: { custom: "true" } }),
expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
])

View file

@ -4,7 +4,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Credential } from "@opencode-ai/core/credential"
import { Config } from "@opencode-ai/core/config"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { WebSearch } from "@opencode-ai/core/websearch"
@ -42,7 +42,7 @@ const http = Layer.succeed(
export const webSearchIntegrationTest = testEffect(
Layer.merge(
AppNodeBuilder.build(
LayerNode.group([Integration.node, Credential.node, EventV2.node, Form.node, WebSearch.node]),
LayerNode.group([Integration.node, Credential.node, Bus.node, Form.node, WebSearch.node]),
[[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))]],
),
http,

View file

@ -9,7 +9,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Git } from "@opencode-ai/core/git"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Project } from "@opencode-ai/core/project"
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
@ -18,7 +18,7 @@ import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([ProjectCopy.node, Database.node, EventV2.node, ProjectDirectories.node])),
AppNodeBuilder.build(LayerNode.group([ProjectCopy.node, Database.node, Bus.node, ProjectDirectories.node])),
)
function abs(input: string) {
@ -116,14 +116,14 @@ describe("ProjectCopy", () => {
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-created"))
const target = abs(path.join(parent, "copy"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
const fiber = yield* events
const fiber = yield* bus
.subscribe(ProjectCopy.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
@ -278,8 +278,8 @@ describe("ProjectCopy", () => {
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const events = yield* EventV2.Service
const event = yield* events.subscribe(ProjectCopy.Event.Updated).pipe(
const bus = yield* Bus.Service
const event = yield* bus.subscribe(ProjectCopy.Event.Updated).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
@ -300,7 +300,7 @@ describe("ProjectCopy", () => {
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const target = abs(`${input.root.path}-copy-external`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
@ -311,7 +311,7 @@ describe("ProjectCopy", () => {
.values({ project_id: input.projectID, directory: target })
.run()
.pipe(Effect.orDie)
const fiber = yield* events
const fiber = yield* bus
.subscribe(ProjectCopy.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow

View file

@ -5,25 +5,25 @@ import path from "path"
import { Effect, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Hash } from "@opencode-ai/util/hash"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.merge(AppNodeBuilder.build(ProjectV2.node), AppNodeBuilder.build(Database.node)))
const it = testEffect(Layer.merge(AppNodeBuilder.build(Project.node), AppNodeBuilder.build(Database.node)))
describe("ProjectV2.list", () => {
describe("Project.list", () => {
it.effect("returns complete projects ordered by recent update", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const project = yield* ProjectV2.Service
const project = yield* Project.Service
yield* db
.insert(ProjectTable)
.values([
{
id: ProjectV2.ID.make("older"),
id: Project.ID.make("older"),
worktree: abs("/older"),
vcs: "git",
name: "Older",
@ -34,7 +34,7 @@ describe("ProjectV2.list", () => {
time_updated: 1,
},
{
id: ProjectV2.ID.make("newer"),
id: Project.ID.make("newer"),
worktree: abs("/newer"),
sandboxes: [],
time_created: 2,
@ -46,13 +46,13 @@ describe("ProjectV2.list", () => {
expect(yield* project.list()).toEqual([
{
id: ProjectV2.ID.make("newer"),
id: Project.ID.make("newer"),
worktree: abs("/newer"),
time: { created: 2, updated: 2, initialized: 3 },
sandboxes: [],
},
{
id: ProjectV2.ID.make("older"),
id: Project.ID.make("older"),
worktree: abs("/older"),
vcs: "git",
name: "Older",
@ -67,7 +67,7 @@ describe("ProjectV2.list", () => {
})
function remoteID(remote: string) {
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
}
function abs(value: string) {
@ -92,18 +92,18 @@ async function rootCommit(dir: string) {
return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim()
}
describe("ProjectV2.resolve", () => {
describe("Project.resolve", () => {
it.live("returns global for non-git directory", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const project = yield* ProjectV2.Service
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(ProjectV2.ID.make("global"))
expect(result.id).toBe(Project.ID.make("global"))
expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
expect(result.previous).toBeUndefined()
expect(result.vcs).toBeUndefined()
@ -117,11 +117,11 @@ describe("ProjectV2.resolve", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path))
const project = yield* ProjectV2.Service
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(ProjectV2.ID.make("global"))
expect(result.id).toBe(Project.ID.make("global"))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
@ -135,11 +135,11 @@ describe("ProjectV2.resolve", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
const project = yield* ProjectV2.Service
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
@ -153,12 +153,12 @@ describe("ProjectV2.resolve", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:Acme/App.git" }))
const project = yield* ProjectV2.Service
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(remoteID("github.com/Acme/App"))
expect(result.id).not.toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.id).not.toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.vcs?.type).toBe("git")
}),
@ -176,7 +176,7 @@ describe("ProjectV2.resolve", () => {
)
yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
const project = yield* ProjectV2.Service
const project = yield* Project.Service
const a = yield* project.resolve(abs(ssh.path))
const b = yield* project.resolve(abs(https.path))
@ -193,11 +193,11 @@ describe("ProjectV2.resolve", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: `file://${tmp.path}` }))
const project = yield* ProjectV2.Service
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
}),
)
@ -209,11 +209,11 @@ describe("ProjectV2.resolve", () => {
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
const project = yield* ProjectV2.Service
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.previous).toBe(ProjectV2.ID.make("old-id"))
expect(result.previous).toBe(Project.ID.make("old-id"))
expect(result.id).toBe(remoteID("github.com/owner/repo"))
}),
)
@ -225,7 +225,7 @@ describe("ProjectV2.resolve", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
const project = yield* ProjectV2.Service
const project = yield* Project.Service
yield* project.resolve(abs(tmp.path))
@ -241,7 +241,7 @@ describe("ProjectV2.resolve", () => {
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true }))
const project = yield* ProjectV2.Service
const project = yield* Project.Service
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
@ -270,13 +270,13 @@ describe("ProjectV2.resolve", () => {
.quiet()
await fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true })
})
const project = yield* ProjectV2.Service
const project = yield* Project.Service
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
expect(result.vcs?.type).toBe("hg")
expect(result.directory).toBe(abs(tmp.path))
expect(result.id).not.toBe(ProjectV2.ID.make("global"))
expect(result.id).not.toBe(Project.ID.make("global"))
expect(result.previous).toBeUndefined()
}),
)
@ -289,7 +289,7 @@ describe("ProjectV2.resolve", () => {
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".hg")))
const project = yield* ProjectV2.Service
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
@ -304,12 +304,12 @@ describe("ProjectV2.resolve", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".hg")))
const project = yield* ProjectV2.Service
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.vcs?.type).toBe("hg")
expect(result.id).toBe(ProjectV2.ID.make("global"))
expect(result.id).toBe(Project.ID.make("global"))
}),
)
@ -326,12 +326,12 @@ describe("ProjectV2.resolve", () => {
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
const project = yield* ProjectV2.Service
const project = yield* Project.Service
const result = yield* project.resolve(abs(worktree))
expect(result.directory).toBe(yield* real(worktree))
expect(result.previous).toBe(ProjectV2.ID.make("old-id"))
expect(result.previous).toBe(Project.ID.make("old-id"))
expect(result.id).toBe(remoteID("github.com/owner/repo"))
expect(result.vcs?.type).toBe("git")
}),

View file

@ -3,7 +3,7 @@ import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
import type { PtyID } from "@opencode-ai/core/pty/schema"
@ -19,7 +19,7 @@ const locationLayer = Layer.succeed(
)
const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [
[Config.node, configLayer],
[Location.node, locationLayer],
]),
@ -27,7 +27,7 @@ const it = testEffect(
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
const source = yield* EventV2.Service
const source = yield* Bus.Service
const events = yield* Queue.unbounded<PtyEvent>()
const unsubscribe = yield* source.listen((event) => {
if (event.type === Pty.Event.Created.type)
@ -206,7 +206,7 @@ describe("pty", () => {
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
const configuredIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [
[
Config.node,
Layer.mock(Config.Service)({

View file

@ -3,7 +3,7 @@ import { Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "../lib/effect"
const it = testEffect(LayerNode.compile(PtyTicket.node))
@ -50,10 +50,10 @@ describe("PTY websocket tickets", () => {
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const ptyID = PtyID.ascending()
const workspaceID = WorkspaceV2.ID.ascending()
const workspaceID = Workspace.ID.ascending()
const issued = yield* tickets.issue({ ptyID, workspaceID })
expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceV2.ID.ascending(), ticket: issued.ticket })).toBe(
expect(yield* tickets.consume({ ptyID, workspaceID: Workspace.ID.ascending(), ticket: issued.ticket })).toBe(
false,
)
expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true)

View file

@ -2,30 +2,31 @@ import { describe, expect } from "bun:test"
import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EventV2 } from "@opencode-ai/core/event"
import { QuestionV2 } from "@opencode-ai/core/question"
import { SessionV2 } from "@opencode-ai/core/session"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { Question } from "@opencode-ai/core/question"
import { Session } from "@opencode-ai/core/session"
import { testEffect } from "./lib/effect"
const questions = AppNodeBuilder.build(LayerNode.group([EventV2.node, QuestionV2.node]))
const questions = AppNodeBuilder.build(LayerNode.group([Bus.node, Question.node]))
const it = testEffect(questions)
const sessionID = SessionV2.ID.make("ses_question_test")
const question: QuestionV2.Info = {
const sessionID = Session.ID.make("ses_question_test")
const question: Question.Info = {
question: "Which option?",
header: "Option",
options: [{ label: "One", description: "First option" }],
}
const waitForAsk = Effect.fn("QuestionV2Test.waitForAsk")(function* (
service: QuestionV2.Interface,
input: QuestionV2.AskInput,
const waitForAsk = Effect.fn("QuestionTest.waitForAsk")(function* (
service: Question.Interface,
input: Question.AskInput,
) {
const events = yield* EventV2.Service
const asked = yield* Deferred.make<QuestionV2.Request>()
const unsubscribe = yield* events.listen((event) =>
event.type === QuestionV2.Event.Asked.type
? Deferred.succeed(asked, event.data as QuestionV2.Request).pipe(Effect.asVoid)
const bus = yield* Bus.Service
const asked = yield* Deferred.make<Question.Request>()
const unsubscribe = yield* bus.listen((event) =>
event.type === Question.Event.Asked.type
? Deferred.succeed(asked, event.data as Question.Request).pipe(Effect.asVoid)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
@ -33,15 +34,15 @@ const waitForAsk = Effect.fn("QuestionV2Test.waitForAsk")(function* (
return { fiber, request: yield* Deferred.await(asked) }
})
describe("QuestionV2", () => {
describe("Question", () => {
it.effect("publishes lifecycle events and settles a pending reply", () =>
Effect.gen(function* () {
const service = yield* QuestionV2.Service
const events = yield* EventV2.Service
const published: EventV2.Payload[] = []
const unsubscribe = yield* events.listen((event) =>
const service = yield* Question.Service
const bus = yield* Bus.Service
const published: Event.Payload[] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type.startsWith("question.v2.")) published.push(event)
if (event.type.startsWith("question.")) published.push(event)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
@ -54,20 +55,20 @@ describe("QuestionV2", () => {
expect(yield* Fiber.join(fiber)).toEqual([["One"]])
expect(yield* service.list()).toEqual([])
expect(published.map((event) => [event.type, event.data])).toEqual([
[QuestionV2.Event.Asked.type, request],
[QuestionV2.Event.Replied.type, { sessionID, requestID: request.id, answers: [["One"]] }],
[Question.Event.Asked.type, request],
[Question.Event.Replied.type, { sessionID, requestID: request.id, answers: [["One"]] }],
])
}),
)
it.effect("publishes rejection, fails the ask, and rejects unknown IDs", () =>
Effect.gen(function* () {
const service = yield* QuestionV2.Service
const events = yield* EventV2.Service
const published: EventV2.Payload[] = []
const unsubscribe = yield* events.listen((event) =>
const service = yield* Question.Service
const bus = yield* Bus.Service
const published: Event.Payload[] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type === QuestionV2.Event.Rejected.type) published.push(event)
if (event.type === Question.Event.Rejected.type) published.push(event)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
@ -76,15 +77,15 @@ describe("QuestionV2", () => {
yield* service.reject(request.id)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError")
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("Question.RejectedError")
expect(published.map((event) => event.data)).toEqual([{ sessionID, requestID: request.id }])
const unknown = QuestionV2.ID.ascending("que_unknown")
const unknown = Question.ID.ascending("que_unknown")
expect(yield* service.reply({ requestID: unknown, answers: [] }).pipe(Effect.flip)).toEqual(
new QuestionV2.NotFoundError({ requestID: unknown }),
new Question.NotFoundError({ requestID: unknown }),
)
expect(yield* service.reject(unknown).pipe(Effect.flip)).toEqual(
new QuestionV2.NotFoundError({ requestID: unknown }),
new Question.NotFoundError({ requestID: unknown }),
)
}),
)
@ -93,21 +94,21 @@ describe("QuestionV2", () => {
Effect.gen(function* () {
const firstScope = yield* Scope.make()
const secondScope = yield* Scope.make()
const first = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), firstScope), QuestionV2.Service)
const second = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), secondScope), QuestionV2.Service)
const first = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), firstScope), Question.Service)
const second = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), secondScope), Question.Service)
const fiber = yield* first.ask({ sessionID, questions: [question] }).pipe(Effect.forkScoped)
yield* Effect.yieldNow
const request = (yield* first.list())[0]!
expect(yield* second.list()).toEqual([])
expect(yield* second.reply({ requestID: request.id, answers: [["One"]] }).pipe(Effect.flip)).toEqual(
new QuestionV2.NotFoundError({ requestID: request.id }),
new Question.NotFoundError({ requestID: request.id }),
)
yield* Scope.close(firstScope, Exit.void)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError")
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("Question.RejectedError")
yield* Scope.close(secondScope, Exit.void)
}),
)

View file

@ -5,14 +5,14 @@ import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Job } from "@opencode-ai/core/job"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionPending } from "@opencode-ai/core/session/pending"
@ -31,10 +31,10 @@ const model = Model.make({
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
})
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
@ -62,7 +62,7 @@ const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// The test only needs the compaction location service used by SessionV2.compact.
// The test only needs the compaction location service used by Session.compact.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
SessionCompaction.layer.pipe(
Layer.provide(client),
@ -73,25 +73,25 @@ const locations = Layer.effect(
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[LocationServiceMap.node, locations],
[ProjectV2.node, projects],
[Project.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
describe("SessionV2.compact", () => {
describe("Session.compact", () => {
it.effect("durably admits and coalesces manual compaction", () =>
Effect.gen(function* () {
requests = []
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
const messageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.InputAdmitted, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID: created.id,
inputID: messageID,
input: {
@ -100,7 +100,7 @@ describe("SessionV2.compact", () => {
delivery: "steer",
},
})
yield* events.publish(SessionEvent.InputPromoted, {
yield* bus.publish(SessionEvent.InputPromoted, {
sessionID: created.id,
inputID: messageID,
})

View file

@ -6,7 +6,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { EventTable } from "@opencode-ai/core/event/sql"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
@ -15,7 +15,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
@ -78,7 +78,7 @@ const models = Layer.mock(SessionRunnerModel.Service)({
})
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionCompaction.node]),
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, SessionCompaction.node]),
[
[llmClient, client],
[Config.node, config],
@ -136,10 +136,10 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
requests = []
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const sessionID = SessionV2.ID.make("ses_manual_compaction")
const parentID = SessionV2.ID.make("ses_manual_compaction_parent")
const sessionID = Session.ID.make("ses_manual_compaction")
const parentID = Session.ID.make("ses_manual_compaction_parent")
const userMessage = {
id: SessionMessage.ID.create(),
type: "user" as const,
@ -174,7 +174,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
),
)
const delta = yield* events
const delta = yield* bus
.subscribe(SessionEvent.Compaction.Delta)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
@ -215,9 +215,9 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
.all()
.pipe(Effect.orDie),
).toEqual([
{ type: EventV2.versionedType(SessionEvent.Compaction.Started.type, 1) },
{ type: EventV2.versionedType(SessionEvent.UsageRecorded.type, 1) },
{ type: EventV2.versionedType(SessionEvent.Compaction.Ended.type, 1) },
{ type: Bus.versionedType(SessionEvent.Compaction.Started.type, 1) },
{ type: Bus.versionedType(SessionEvent.UsageRecorded.type, 1) },
{ type: Bus.versionedType(SessionEvent.Compaction.Ended.type, 1) },
])
}),
)

View file

@ -2,20 +2,20 @@ import { describe, expect } from "bun:test"
import path from "path"
import { DateTime, Effect, Layer, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Agent } from "@opencode-ai/core/agent"
import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Model } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@ -24,42 +24,42 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[ProjectV2.node, projects],
[Project.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const id = SessionV2.ID.create()
const id = Session.ID.create()
/** Public session events from a `log` read, without synced markers. */
const logEvents = (session: SessionV2.Interface, sessionID: SessionV2.ID, follow?: boolean) =>
const logEvents = (session: Session.Interface, sessionID: Session.ID, follow?: boolean) =>
session
.log({ sessionID, follow })
.pipe(Stream.filter((item): item is SessionEvent.DurableEvent => !EventV2.isSynced(item)))
.pipe(Stream.filter((item): item is SessionEvent.DurableEvent => !Bus.isSynced(item)))
const assertCreateInputTypes = (session: SessionV2.Interface) => {
const assertCreateInputTypes = (session: Session.Interface) => {
// @ts-expect-error location or parentID is required.
session.create({})
// @ts-expect-error child sessions inherit their parent's location.
session.create({ parentID: SessionV2.ID.create(), location })
session.create({ parentID: Session.ID.create(), location })
}
void assertCreateInputTypes
@ -70,10 +70,10 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("SessionV2.create", () => {
describe("Session.create", () => {
it.effect("creates a fresh projected session when the ID is omitted", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const first = yield* session.create({ location })
const second = yield* session.create({ location })
@ -85,7 +85,7 @@ describe("SessionV2.create", () => {
it.effect("returns the original session when the ID is retried", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const input = { id, location }
const first = yield* session.create(input)
@ -98,18 +98,18 @@ describe("SessionV2.create", () => {
it.effect("stores supplied immutable create attributes", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const workspaceID = WorkspaceV2.ID.make("wrk_test")
const model = ModelV2.Ref.make({
id: ModelV2.ID.make("sonnet"),
providerID: ProviderV2.ID.anthropic,
variant: ModelV2.VariantID.make("fast"),
const session = yield* Session.Service
const workspaceID = Workspace.ID.make("wrk_test")
const model = Model.Ref.make({
id: Model.ID.make("sonnet"),
providerID: Provider.ID.anthropic,
variant: Model.VariantID.make("fast"),
})
expect(
yield* session.create({
location: Location.Ref.make({ directory: location.directory, workspaceID }),
agent: AgentV2.ID.make("build"),
agent: Agent.ID.make("build"),
model,
}),
).toMatchObject({ location: { directory: location.directory, workspaceID }, agent: "build", model })
@ -118,7 +118,7 @@ describe("SessionV2.create", () => {
it.effect("inherits location from an existing parent when omitted", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const parent = yield* session.create({ location })
const child = yield* session.create({ parentID: parent.id, title: "child" })
@ -128,18 +128,18 @@ describe("SessionV2.create", () => {
it.effect("rejects child creation when the parent does not exist", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const missing = SessionV2.ID.create()
const session = yield* Session.Service
const missing = Session.ID.create()
expect(yield* Effect.flip(session.create({ parentID: missing, title: "child" }))).toEqual(
new SessionV2.NotFoundError({ sessionID: missing }),
new Session.NotFoundError({ sessionID: missing }),
)
}),
)
it.effect("filters root sessions before applying the page limit", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const { db } = yield* Database.Service
const staleRoot = yield* session.create({ location, title: "stale root" })
const root = yield* session.create({ location, title: "root" })
@ -173,7 +173,7 @@ describe("SessionV2.create", () => {
it.effect("filters direct child sessions by parent ID", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const parent = yield* session.create({ location, title: "parent" })
const child = yield* session.create({ parentID: parent.id, title: "child" })
yield* session.create({ location, title: "other root" })
@ -186,8 +186,8 @@ describe("SessionV2.create", () => {
it.effect("forks a session by replaying a durable fork event into copied projected rows", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location, title: "Parent" })
const admitted = yield* session.prompt({
@ -195,9 +195,9 @@ describe("SessionV2.create", () => {
text: "First",
resume: false,
})
yield* SessionPending.promote(db, events, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
yield* SessionPending.promote(db, events, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id })
const parentContext = yield* session.context(parent.id)
@ -232,13 +232,13 @@ describe("SessionV2.create", () => {
text: "Parent changed",
resume: false,
})
yield* SessionPending.promote(db, events, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
yield* session.prompt({
sessionID: forked.id,
text: "Child continues",
resume: false,
})
yield* SessionPending.promote(db, events, forked.id, "steer")
yield* SessionPending.promote(db, bus, forked.id, "steer")
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
@ -254,8 +254,8 @@ describe("SessionV2.create", () => {
it.effect("forks before the selected boundary message", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location })
const first = yield* session.prompt({
@ -263,22 +263,22 @@ describe("SessionV2.create", () => {
text: "First",
resume: false,
})
yield* SessionPending.promote(db, events, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
const second = yield* session.prompt({
sessionID: parent.id,
text: "Second",
resume: false,
})
yield* SessionPending.promote(db, events, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
const assistantMessageID = SessionMessage.ID.create()
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
yield* events.publish(SessionEvent.Step.Started, {
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: parent.id,
assistantMessageID,
agent: AgentV2.ID.make("build"),
agent: Agent.ID.make("build"),
model,
})
yield* events.publish(SessionEvent.Step.Ended, {
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: parent.id,
assistantMessageID,
finish: "stop",
@ -308,15 +308,15 @@ describe("SessionV2.create", () => {
it.effect("returns the existing Session when one ID is reused with different create arguments", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const created = yield* session.create({ id, location })
const changed = [
{ id, location: Location.Ref.make({ directory: AbsolutePath.make("/other") }) },
{ id, location, agent: AgentV2.ID.make("build") },
{ id, location, agent: Agent.ID.make("build") },
{
id,
location,
model: ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }),
model: Model.Ref.make({ id: Model.ID.make("sonnet"), providerID: Provider.ID.anthropic }),
},
]
@ -329,7 +329,7 @@ describe("SessionV2.create", () => {
it.effect("returns one recorded session to concurrent exact retries", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const input = { id, location }
const created = yield* Effect.all([session.create(input), session.create(input)], { concurrency: "unbounded" })
@ -341,7 +341,7 @@ describe("SessionV2.create", () => {
it.effect("returns the current Session projection after updates", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const { db } = yield* Database.Service
const input = { id, location }
const created = yield* session.create(input)
@ -354,12 +354,12 @@ describe("SessionV2.create", () => {
it.effect("returns the current Session projection after projected updates", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
const input = { id, location }
const created = yield* session.create(input)
yield* events.publish(SessionV1.Event.Updated, {
yield* bus.publish(SessionV1.Event.Updated, {
sessionID: id,
info: SessionV1.SessionInfo.make({
id,
@ -379,19 +379,19 @@ describe("SessionV2.create", () => {
it.effect("persists creation through the existing legacy created event", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const { db } = yield* Database.Service
const created = yield* session.create({ location })
expect(
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
).toMatchObject([{ type: EventV2.versionedType(SessionV1.Event.Created.type, 1) }])
).toMatchObject([{ type: Bus.versionedType(SessionV1.Event.Created.type, 1) }])
}),
)
it.effect("persists caller-ID creation through the existing created event", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const { db } = yield* Database.Service
const created = yield* session.create({ id, location })
@ -403,10 +403,10 @@ describe("SessionV2.create", () => {
}),
)
it.effect("omits legacy creation rows from the V2 Session event stream", () =>
it.effect("omits legacy creation rows from the Session event stream", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const created = yield* session.create({ location })
yield* session.prompt({
@ -414,7 +414,7 @@ describe("SessionV2.create", () => {
text: "Hello",
resume: false,
})
yield* SessionPending.promote(db, events, created.id, "steer")
yield* SessionPending.promote(db, bus, created.id, "steer")
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
@ -431,10 +431,10 @@ describe("SessionV2.create", () => {
it.effect("replays one prompt lifecycle into a fresh target database", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const sourceEvents = yield* EventV2.Service
const session = yield* Session.Service
const sourceEvents = yield* Bus.Service
const sourceDb = (yield* Database.Service).db
const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location })
const created = yield* session.create({ id: Session.ID.make("ses_fresh_target_replay"), location })
const admitted = yield* session.prompt({
sessionID: created.id,
text: "Replay lifecycle",
@ -462,22 +462,22 @@ describe("SessionV2.create", () => {
)
const targetDatabase = Database.layer({ path: path.join(tmp.path, "target.sqlite") })
const targetLayer = AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node]),
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
[[Database.node, targetDatabase]],
)
yield* Effect.gen(function* () {
const db = (yield* Database.Service).db
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
yield* db
.insert(ProjectTable)
.values({ id: ProjectV2.ID.global, worktree: location.directory, sandboxes: [] })
.values({ id: Project.ID.global, worktree: location.directory, sandboxes: [] })
.run()
.pipe(Effect.orDie)
expect(yield* store.get(created.id)).toBeUndefined()
expect(yield* events.replayAll(serialized.slice(0, 2))).toBe(created.id)
expect(yield* bus.replayAll(serialized.slice(0, 2))).toBe(created.id)
expect(yield* SessionPending.find(db, admitted.id)).toMatchObject({
id: admitted.id,
sessionID: created.id,
@ -488,7 +488,7 @@ describe("SessionV2.create", () => {
})
expect(yield* store.context(created.id)).toEqual([])
expect(yield* events.replayAll(serialized.slice(2))).toBe(created.id)
expect(yield* bus.replayAll(serialized.slice(2))).toBe(created.id)
expect(yield* SessionPending.find(db, admitted.id)).toBeUndefined()
expect(yield* store.context(created.id)).toMatchObject([
{ id: admitted.id, type: "user", text: "Replay lifecycle" },
@ -502,9 +502,9 @@ describe("SessionV2.create", () => {
.all()
.pipe(Effect.orDie)).map((event) => [event.seq, event.type]),
).toEqual([
[0, EventV2.versionedType(SessionV1.Event.Created.type, 1)],
[1, EventV2.versionedType(SessionEvent.InputAdmitted.type, 1)],
[2, EventV2.versionedType(SessionEvent.InputPromoted.type, 1)],
[0, Bus.versionedType(SessionV1.Event.Created.type, 1)],
[1, Bus.versionedType(SessionEvent.InputAdmitted.type, 1)],
[2, Bus.versionedType(SessionEvent.InputPromoted.type, 1)],
])
}).pipe(Effect.provide(Layer.fresh(targetLayer)))
}),
@ -512,8 +512,8 @@ describe("SessionV2.create", () => {
it.effect("does not mask unrelated created projector defects", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const event = yield* EventV2.Service
const session = yield* Session.Service
const event = yield* Bus.Service
const defect = new Error("unrelated projector defect")
yield* event.project(SessionV1.Event.Created, () => Effect.die(defect))
@ -524,7 +524,7 @@ describe("SessionV2.create", () => {
it.live("runs a shell command and projects the started/ended shell message", () =>
withTmp((directory) =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const created = yield* session.create({
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
})
@ -544,7 +544,7 @@ describe("SessionV2.create", () => {
it.live("still emits shell ended for a failing command", () =>
withTmp((directory) =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const created = yield* session.create({
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
})
@ -562,10 +562,10 @@ describe("SessionV2.create", () => {
it.effect("switches the selected agent through the durable Session event", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const created = yield* session.create({ location })
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("plan") })
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") })
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
expect(
@ -576,11 +576,11 @@ describe("SessionV2.create", () => {
it.effect("rejects an agent switch for a missing Session", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const missing = SessionV2.ID.make("ses_missing_agent_switch")
const session = yield* Session.Service
const missing = Session.ID.make("ses_missing_agent_switch")
expect(
yield* session.switchAgent({ sessionID: missing, agent: AgentV2.ID.make("plan") }).pipe(
yield* session.switchAgent({ sessionID: missing, agent: Agent.ID.make("plan") }).pipe(
Effect.flip,
Effect.map((error) => error._tag),
),
@ -590,28 +590,28 @@ describe("SessionV2.create", () => {
it.effect("switches the selected model through the durable Session event", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const created = yield* session.create({ location })
const model = ModelV2.Ref.make({
id: ModelV2.ID.make("sonnet"),
providerID: ProviderV2.ID.anthropic,
variant: ModelV2.VariantID.make("high"),
const model = Model.Ref.make({
id: Model.ID.make("sonnet"),
providerID: Provider.ID.anthropic,
variant: Model.VariantID.make("high"),
})
yield* session.switchModel({ sessionID: created.id, model })
expect(yield* session.get(created.id)).toMatchObject({ model })
const events = Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect))
expect(events).toMatchObject([{ type: "session.model.selected" }])
expect(events[0]?.data).toEqual({ sessionID: created.id, model })
const bus = Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect))
expect(bus).toMatchObject([{ type: "session.model.selected" }])
expect(bus[0]?.data).toEqual({ sessionID: created.id, model })
}),
)
it.effect("ignores a model switch when the selected model is unchanged", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const created = yield* session.create({ location })
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic })
const model = Model.Ref.make({ id: Model.ID.make("sonnet"), providerID: Provider.ID.anthropic })
yield* session.switchModel({ sessionID: created.id, model })
yield* session.switchModel({ sessionID: created.id, model })
@ -626,13 +626,13 @@ describe("SessionV2.create", () => {
it.effect("treats an omitted variant as the default variant", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic })
const session = yield* Session.Service
const model = Model.Ref.make({ id: Model.ID.make("sonnet"), providerID: Provider.ID.anthropic })
const created = yield* session.create({ location, model })
yield* session.switchModel({
sessionID: created.id,
model: ModelV2.Ref.make({ ...model, variant: ModelV2.VariantID.make("default") }),
model: Model.Ref.make({ ...model, variant: Model.VariantID.make("default") }),
})
const { db } = yield* Database.Service
@ -644,14 +644,14 @@ describe("SessionV2.create", () => {
it.effect("rejects a model switch for a missing Session", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const missing = SessionV2.ID.make("ses_missing_model_switch")
const session = yield* Session.Service
const missing = Session.ID.make("ses_missing_model_switch")
expect(
yield* session
.switchModel({
sessionID: missing,
model: ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }),
model: Model.Ref.make({ id: Model.ID.make("sonnet"), providerID: Provider.ID.anthropic }),
})
.pipe(
Effect.flip,

View file

@ -15,8 +15,8 @@ import {
UnknownProviderReason,
ToolFailure,
} from "@opencode-ai/ai"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
import { Permission } from "@opencode-ai/core/permission"
import { Tool } from "@opencode-ai/schema/tool"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
@ -56,7 +56,7 @@ describe("toSessionError", () => {
})
test("preserves the permission rejection type without exposing internal fields", () => {
const blocked = new PermissionV2.BlockedError({ rules: [], permission: "external_directory", resources: [] })
const blocked = new Permission.BlockedError({ rules: [], permission: "external_directory", resources: [] })
expect(toSessionError(blocked)).toEqual({
type: "permission.rejected",
message: "Permission denied: external_directory",
@ -65,7 +65,7 @@ describe("toSessionError", () => {
type: "permission.rejected",
message: "Permission denied: external_directory",
})
expect(toSessionError(new Tool.Failure({ message: "failed" }))).toEqual({
expect(toSessionError(new Tool.Error({ message: "failed" }))).toEqual({
type: "tool.execution",
message: "failed",
})

View file

@ -3,24 +3,23 @@ import { LLMError, TransportReason } from "@opencode-ai/ai"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { UserInterruptedError } from "@opencode-ai/core/session/error"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionStore.node])))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node])))
describe("SessionExecution lifecycle", () => {
test("classifies success and typed failure terminals", () => {
@ -36,11 +35,6 @@ describe("SessionExecution lifecycle", () => {
),
),
).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } })
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
expect(SessionExecution.terminal(Exit.fail(storage))).toEqual({
type: "failed",
error: { type: "unknown", message: storage.message },
})
})
test("defaults owner-scope interruption to shutdown and preserves explicit reasons", () => {
@ -58,8 +52,8 @@ describe("SessionExecution lifecycle", () => {
Effect.gen(function* () {
const database = yield* Database.Service
const store = yield* SessionStore.Service
const first = SessionV2.ID.make("ses_recover_first")
const second = SessionV2.ID.make("ses_recover_second")
const first = Session.ID.make("ses_recover_first")
const second = Session.ID.make("ses_recover_second")
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
expect(yield* store.consumeSuspended(first)).toBe(true)
@ -72,8 +66,8 @@ describe("SessionExecution lifecycle", () => {
it.effect("suspension survives teardown interruption and clears when a drain finishes on its own", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const interrupted = SessionV2.ID.make("ses_suspend_interrupted")
const completed = SessionV2.ID.make("ses_suspend_completed")
const interrupted = Session.ID.make("ses_suspend_interrupted")
const completed = Session.ID.make("ses_suspend_completed")
yield* seedSessions(database, [interrupted, completed])
const draining = yield* Deferred.make<void>()
@ -108,11 +102,11 @@ describe("SessionExecution lifecycle", () => {
it.effect("starts every suspended execution without waiting for earlier drains to finish", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const sessionIDs = Array.from({ length: 5 }, (_, index) => SessionV2.ID.make(`ses_resume_concurrent_${index}`))
const sessionIDs = Array.from({ length: 5 }, (_, index) => Session.ID.make(`ses_resume_concurrent_${index}`))
yield* seedSessions(database, sessionIDs, { time_suspended: Date.now() })
const fourStarted = yield* Deferred.make<void>()
const started: SessionV2.ID[] = []
const started: Session.ID[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, ({ sessionID }) =>
@ -133,8 +127,8 @@ describe("SessionExecution lifecycle", () => {
it.effect("resumes each suspended Session at most once", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const first = SessionV2.ID.make("ses_resume_first")
const second = SessionV2.ID.make("ses_resume_second")
const first = Session.ID.make("ses_resume_first")
const second = Session.ID.make("ses_resume_second")
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
const drained: string[] = []
@ -157,7 +151,7 @@ describe("SessionExecution lifecycle", () => {
function seedSessions(
database: Database.Service["Service"],
sessionIDs: ReadonlyArray<SessionV2.ID>,
sessionIDs: ReadonlyArray<Session.ID>,
values: { time_suspended?: number } = {},
) {
return Effect.gen(function* () {
@ -199,7 +193,7 @@ function suspensions(database: Database.Service["Service"]) {
function buildExecution(scope: Scope.Closeable, drain: SessionRunner.Interface["drain"]) {
return Effect.gen(function* () {
const database = yield* Database.Service
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const runner = Layer.succeed(SessionRunner.Service, SessionRunner.Service.of({ drain }))
const locations = Layer.effect(
@ -215,7 +209,7 @@ function buildExecution(scope: Scope.Closeable, drain: SessionRunner.Interface["
SessionRestart.layer.pipe(
Layer.provideMerge(SessionExecution.layer),
Layer.provide(Layer.succeed(Database.Service, database)),
Layer.provide(Layer.succeed(EventV2.Service, events)),
Layer.provide(Layer.succeed(Bus.Service, bus)),
Layer.provide(Layer.succeed(SessionStore.Service, store)),
Layer.provide(locations),
),

View file

@ -1,22 +1,22 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, ToolDefinition, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Agent } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { EventTable } from "@opencode-ai/core/event/sql"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { Location } from "@opencode-ai/core/location"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { ModelV2 } from "@opencode-ai/core/model"
import { ID } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionEvent } from "@opencode-ai/core/session/event"
@ -38,7 +38,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Tool } from "@opencode-ai/core/tool"
import { asc, eq } from "drizzle-orm"
import { Effect, Layer, Schema, Stream } from "effect"
import { testEffect } from "./lib/effect"
@ -98,7 +98,7 @@ const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succee
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const tools = Layer.mock(ToolRegistry.Service, {
const tools = Layer.mock(Tool.Service, {
snapshot: () =>
Effect.succeed({
codeModeCatalog: [
@ -111,18 +111,17 @@ const tools = Layer.mock(ToolRegistry.Service, {
definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
execute: () => Effect.die(new Error("unused")),
}),
register: () => Effect.die(new Error("unused")),
registerBatch: () => Effect.die(new Error("unused")),
transform: () => Effect.die(new Error("unused")),
})
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
AgentV2.node,
Agent.node,
InstructionBuiltIns.node,
PluginHooks.node,
SessionGenerateNode.node,
@ -136,7 +135,7 @@ const it = testEffect(
[ReferenceInstructions.node, references],
[McpInstructions.node, mcp],
[PluginSupervisor.node, plugins],
[ToolRegistry.node, tools],
[Tool.node, tools],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
],
),
@ -144,8 +143,8 @@ const it = testEffect(
const durableState = (db: Database.Interface["db"], sessionID: SessionSchema.ID) =>
Effect.all({
sequence: EventV2.latestSequence(db, sessionID),
events: db
sequence: Bus.latestSequence(db, sessionID),
bus: db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
@ -185,11 +184,11 @@ const userTexts = (request: LLMRequest) =>
const setup = Effect.gen(function* () {
const { db } = yield* Database.Service
const events = yield* EventV2.Service
const agents = yield* AgentV2.Service
const bus = yield* Bus.Service
const agents = yield* Agent.Service
const instructionBuiltIns = yield* InstructionBuiltIns.Service
yield* agents.transform((draft) =>
draft.update(AgentV2.ID.make("build"), (agent) => {
draft.update(Agent.ID.make("build"), (agent) => {
agent.mode = "primary"
}),
)
@ -207,71 +206,71 @@ const setup = Effect.gen(function* () {
directory: "/project",
title: "Generate test",
version: "test",
agent: AgentV2.ID.make("build"),
agent: Agent.ID.make("build"),
})
.run()
.pipe(Effect.orDie)
return { db, events, instructions: yield* instructionBuiltIns.load(sessionID) }
return { db, bus, instructions: yield* instructionBuiltIns.load(sessionID) }
})
it.effect("generates from fresh settled Session context without durable mutation", () =>
Effect.gen(function* () {
requests.length = 0
instruction = "Initial context"
const { db, events, instructions } = yield* setup
yield* InstructionState.prepare(db, events, instructions, sessionID)
const { db, bus, instructions } = yield* setup
yield* InstructionState.prepare(db, bus, instructions, sessionID)
const existing = SessionMessage.ID.create()
yield* events.publish(SessionEvent.InputAdmitted, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID,
inputID: existing,
input: { type: "user", data: { text: "Existing durable context" }, delivery: "steer" },
})
yield* events.publish(SessionEvent.InputPromoted, { sessionID, inputID: existing })
yield* bus.publish(SessionEvent.InputPromoted, { sessionID, inputID: existing })
const settledAssistant = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: settledAssistant,
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("generate-model"), providerID: ProviderV2.ID.make("test") },
agent: Agent.ID.make("build"),
model: { id: ID.make("generate-model"), providerID: Provider.ID.make("test") },
})
yield* events.publish(SessionEvent.Text.Started, {
yield* bus.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID: settledAssistant,
ordinal: 0,
})
yield* events.publish(SessionEvent.Text.Ended, {
yield* bus.publish(SessionEvent.Text.Ended, {
sessionID,
assistantMessageID: settledAssistant,
ordinal: 0,
text: "Settled partial answer",
})
const activeAssistant = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: activeAssistant,
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("generate-model"), providerID: ProviderV2.ID.make("test") },
agent: Agent.ID.make("build"),
model: { id: ID.make("generate-model"), providerID: Provider.ID.make("test") },
})
yield* events.publish(SessionEvent.Tool.Input.Started, {
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
name: "echo",
})
yield* events.publish(SessionEvent.Tool.Input.Ended, {
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
text: "{}",
})
yield* events.publish(SessionEvent.Tool.Called, {
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
input: {},
executed: false,
})
yield* events.publish(SessionEvent.InputAdmitted, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID,
inputID: SessionMessage.ID.create(),
input: { type: "user", data: { text: "Queued input must remain invisible" }, delivery: "queue" },

View file

@ -3,22 +3,22 @@ import fs from "fs/promises"
import path from "path"
import { DateTime, Effect, Layer } from "effect"
import { Message } from "@opencode-ai/ai"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "@opencode-ai/core/image"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { ModelV2 } from "@opencode-ai/core/model"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ReadTool } from "@opencode-ai/core/tool/read"
import { Model } from "@opencode-ai/core/model"
import { Permission } from "@opencode-ai/core/permission"
import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider"
import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@ -26,11 +26,10 @@ import { SessionInstructions } from "@opencode-ai/core/session/instructions"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Tool } from "@opencode-ai/core/tool"
import { tempLocationLayer } from "./fixture/location"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
@ -40,11 +39,11 @@ const readToolNode = makeLocationNode({
name: "test/read-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(ReadTool.Plugin)),
deps: [
ToolRegistry.toolsNode,
Tool.node,
ReadToolFileSystem.node,
LocationMutation.node,
Image.node,
PermissionV2.node,
Permission.node,
SessionInstructions.node,
FSUtil.node,
Location.node,
@ -52,17 +51,17 @@ const readToolNode = makeLocationNode({
})
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
Permission.Service,
Permission.Service.of({
assert: () => Effect.void,
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
@ -77,41 +76,39 @@ const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
const testLayer = AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
SessionV2.node,
Session.node,
Location.node,
FSUtil.node,
LocationMutation.node,
ReadToolFileSystem.node,
readToolNode,
ToolRegistry.node,
ToolRegistry.toolsNode,
ToolHooks.node,
Tool.node,
Tool.node,
PluginHooks.node,
SessionInstructions.node,
Global.node,
ToolOutputStore.node,
Image.node,
]),
[
[ProjectV2.node, projects],
[Project.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
[Location.node, tempLocationLayer],
[PermissionV2.node, permission],
[Permission.node, permission],
[Config.node, config],
[Image.node, imageLayer],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
) as unknown as Layer.Layer<unknown>
const it = testEffect(testLayer)
const identity = {
agent: AgentV2.ID.make("build"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_nearby"),
}
const readCall = (sessionID: SessionV2.ID, id: string, readPath: string): ToolRegistry.ExecuteInput => ({
const readCall = (sessionID: Session.ID, id: string, readPath: string): Parameters<Tool.Snapshot["execute"]>[0] => ({
sessionID,
...identity,
call: { type: "tool-call", id, name: "read", input: { path: readPath } },
@ -120,7 +117,7 @@ const readCall = (sessionID: SessionV2.ID, id: string, readPath: string): ToolRe
const writeAgents = (file: string, content: string) => Effect.promise(() => fs.writeFile(file, content))
const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
const synthetics = (sessionID: SessionV2.ID) =>
const synthetics = (sessionID: Session.ID) =>
Effect.gen(function* () {
const store = yield* SessionStore.Service
return (yield* store.context(sessionID)).filter((message) => message.type === "synthetic")
@ -128,10 +125,10 @@ const synthetics = (sessionID: SessionV2.ID) =>
// Seed a prior synthetic message with an instruction dedup ledger, simulating a prior turn
// after the Location layer was reopened (in-memory set empty).
const seedSynthetic = (sessionID: SessionV2.ID, paths: string[]) =>
const seedSynthetic = (sessionID: Session.ID, paths: string[]) =>
Effect.gen(function* () {
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.Synthetic, {
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: `Instructions from: ${paths[0]}\nprior`,
description: `Loaded ${paths[0]}`,
@ -157,8 +154,8 @@ describe("SessionInstructions", () => {
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "deep", "file.txt"), "file content"))
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "other", "file2.txt"), "file content 2"))
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
const session = yield* Session.Service
const registry = yield* Tool.Service
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
// A read deep under sub/ discovers deep and sub AGENTS.md, walking up to but
@ -201,8 +198,8 @@ describe("SessionInstructions", () => {
yield* writeAgents(subPath, "sub-instructions")
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "file.txt"), "content"))
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
const session = yield* Session.Service
const registry = yield* Tool.Service
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
// Seed the durable history with a prior synthetic that already claims sub's AGENTS.md
@ -230,8 +227,8 @@ describe("SessionInstructions", () => {
yield* writeAgents(pkgPath, "pkg-instructions")
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "packages", "foo", "file.txt"), "content"))
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
const session = yield* Session.Service
const registry = yield* Tool.Service
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
// Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
@ -263,8 +260,8 @@ describe("SessionInstructions", () => {
yield* writeAgents(rootPath, "root-instructions")
yield* writeAgents(subPath, "sub-instructions")
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
const session = yield* Session.Service
const registry = yield* Tool.Service
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
// The walk starts and stops at the Location root: the root AGENTS.md is searched but
@ -283,7 +280,7 @@ describe("SessionInstructions", () => {
yield* mkdir(path.resolve(dir, "sub"))
yield* writeAgents(subPath, "sub-instructions")
const session = yield* SessionV2.Service
const session = yield* Session.Service
const sessionInstructions = yield* SessionInstructions.Service
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
@ -299,7 +296,7 @@ describe("SessionInstructions", () => {
test("toLLMMessages does not forward synthetic metadata to the provider", () => {
const created = DateTime.makeUnsafe(0)
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
const synthetic = SessionMessage.Synthetic.make({
id: SessionMessage.ID.make("msg_synthetic"),
type: "synthetic",

View file

@ -1,15 +1,16 @@
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { Location } from "@opencode-ai/core/location"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionStore } from "@opencode-ai/core/session/store"
@ -17,35 +18,35 @@ import { SessionTable } from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect"
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[ProjectV2.node, projects],
[Project.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
describe("SessionV2.log", () => {
describe("Session.log", () => {
it.effect("replays public session events and marks synced at the aggregate watermark", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
yield* session.rename({ sessionID: created.id, title: "session.renamed" })
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
const watermark = (yield* events.sequences([created.id])).get(created.id)
const watermark = (yield* bus.sequences([created.id])).get(created.id)
// Session creation commits a non-public durable event, so the marker's
// seq covers more of the aggregate than the public events emitted.
@ -56,7 +57,7 @@ describe("SessionV2.log", () => {
it.effect("continues with live public events when following", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const created = yield* session.create({ location })
const fiber = yield* session
.log({ sessionID: created.id, follow: true })
@ -72,52 +73,52 @@ describe("SessionV2.log", () => {
it.effect("fails with NotFound for an unknown session", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const error = yield* Effect.flip(Stream.runCollect(session.log({ sessionID: SessionV2.ID.create() })))
const session = yield* Session.Service
const error = yield* Effect.flip(Stream.runCollect(session.log({ sessionID: Session.ID.create() })))
expect(error._tag).toBe("Session.NotFoundError")
}),
)
it.effect("reads across undecodable gaps in aggregate order and marks the true log position", () =>
Effect.gen(function* () {
const GapEvent = EventV2.durable({
const GapEvent = Bus.durable({
type: "test.session.log.gap",
durable: { aggregate: "sessionID", version: 1 },
schema: { sessionID: SessionV2.ID, value: Schema.String },
schema: { sessionID: Session.ID, value: Schema.String },
})
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("one") })
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("one") })
// Not in the durable manifest, so reads must skip it without failing.
yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" })
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("two") })
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("three") })
yield* bus.publish(GapEvent, { sessionID: created.id, value: "filtered" })
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("two") })
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("three") })
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, after: 1 })))
expect(
items.map((item): number | string | undefined => (EventV2.isSynced(item) ? item.type : item.durable?.seq)),
items.map((item): number | string | undefined => (Bus.isSynced(item) ? item.type : item.durable?.seq)),
).toEqual([3, 4, "log.synced"])
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: EventV2.Seq.make(4) })
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(4) })
}),
)
it.effect("completes with a bare synced marker for a migrated Session with no event sequence", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const session = yield* SessionV2.Service
const sessionID = SessionV2.ID.make("ses_empty_log")
const session = yield* Session.Service
const sessionID = Session.ID.make("ses_empty_log")
yield* db
.insert(ProjectTable)
.values({ id: ProjectV2.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: ProjectV2.ID.global,
project_id: Project.ID.global,
slug: "empty-log",
directory: "/project",
title: "Empty log",

View file

@ -2,17 +2,18 @@ import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect"
import { asc, eq, sql } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Agent } from "@opencode-ai/core/agent"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { ModelV2 } from "@opencode-ai/core/model"
import { Model } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Money } from "@opencode-ai/schema/money"
@ -31,14 +32,14 @@ import {
import { testEffect } from "./lib/effect"
import { Snapshot } from "@opencode-ai/core/snapshot"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node])))
const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]])
const sessionID = SessionV2.ID.make("ses_projector_test")
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node])))
const sessionsLayer = AppNodeBuilder.build(Session.node, [[SessionExecution.node, SessionExecution.noopLayer]])
const sessionID = Session.ID.make("ses_projector_test")
const created = DateTime.makeUnsafe(0)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
const previousModel = { ...model, variant: ModelV2.VariantID.make("medium") }
const model = { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }
const previousModel = { ...model, variant: Model.VariantID.make("medium") }
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const build = AgentV2.defaultID
const build = Agent.defaultID
const assistantRow = (
id: SessionMessage.ID,
@ -75,11 +76,11 @@ describe("SessionProjector", () => {
version: "test",
})
.run()
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const inputID = SessionMessage.ID.make("msg_manual_compaction")
yield* SessionPending.admitCompaction(db, events, { id: inputID, sessionID })
yield* SessionPending.admitCompaction(db, bus, { id: inputID, sessionID })
yield* events.publish(SessionEvent.Compaction.Failed, {
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "auto",
error: { type: "compaction.failed", message: "Auto compaction failed" },
@ -143,7 +144,7 @@ describe("SessionProjector", () => {
yield* SessionMessageUpdater.update(
SessionMessageUpdater.memory(state),
SessionEvent.Compaction.Delta.make({
id: EventV2.ID.make("evt_delta"),
id: Event.ID.make("evt_delta"),
type: "session.compaction.delta",
created,
data: { sessionID, text: "summary" },
@ -213,8 +214,8 @@ describe("SessionProjector", () => {
current_values: {},
})
.run()
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Staged, {
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), files: [] },
})
@ -223,13 +224,13 @@ describe("SessionProjector", () => {
snapshot: "tree",
files: [],
})
yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID })
yield* bus.publish(SessionEvent.RevertEvent.Cleared, { sessionID })
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toBeNull()
yield* events.publish(SessionEvent.RevertEvent.Staged, {
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
revert: { messageID: boundary, files: [] },
})
yield* events.publish(SessionEvent.RevertEvent.Committed, {
yield* bus.publish(SessionEvent.RevertEvent.Committed, {
sessionID,
to: boundary,
})
@ -269,36 +270,36 @@ describe("SessionProjector", () => {
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const bus = yield* Bus.Service
yield* events.publish(SessionEvent.InputAdmitted, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID,
inputID: SessionMessage.ID.make("msg_first"),
input: { type: "user", data: { text: "first" }, delivery: "steer" },
})
yield* events.publish(
yield* bus.publish(
SessionEvent.InputPromoted,
{
sessionID,
inputID: SessionMessage.ID.make("msg_first"),
},
{ id: EventV2.ID.make("evt_z") },
{ id: Event.ID.make("evt_z") },
)
yield* events.publish(SessionEvent.InputAdmitted, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID,
inputID: SessionMessage.ID.make("msg_second"),
input: { type: "user", data: { text: "second" }, delivery: "steer" },
})
yield* events.publish(
yield* bus.publish(
SessionEvent.InputPromoted,
{
sessionID,
inputID: SessionMessage.ID.make("msg_second"),
},
{ id: EventV2.ID.make("evt_a") },
{ id: Event.ID.make("evt_a") },
)
const sessions = yield* SessionV2.Service
const sessions = yield* Session.Service
const firstPage = yield* sessions.messages({ sessionID, limit: 1, order: "asc" })
expect(firstPage.map((message) => (message.type === "user" ? message.text : message.type))).toEqual(["first"])
const secondPage = yield* sessions.messages({
@ -342,16 +343,16 @@ describe("SessionProjector", () => {
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const id = SessionMessage.ID.make("msg_admitted")
const admitted = yield* SessionPending.admit(db, events, {
const admitted = yield* SessionPending.admit(db, bus, {
id,
sessionID,
input: { type: "user", data: { text: "promote me" }, delivery: "steer" },
})
if (!admitted) return yield* Effect.die("Prompt admission failed")
const event = yield* events.publish(SessionEvent.InputPromoted, {
const event = yield* bus.publish(SessionEvent.InputPromoted, {
sessionID,
inputID: id,
})
@ -386,22 +387,22 @@ describe("SessionProjector", () => {
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const bus = yield* Bus.Service
yield* events.publish(SessionEvent.AgentSelected, {
yield* bus.publish(SessionEvent.AgentSelected, {
sessionID,
agent: build,
})
yield* events.publish(SessionEvent.ModelSelected, {
yield* bus.publish(SessionEvent.ModelSelected, {
sessionID,
model,
})
yield* events.publish(SessionEvent.Synthetic, {
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: "synthetic context",
metadata: { source: "projector-test" },
})
yield* events.publish(SessionEvent.Shell.Started, {
yield* bus.publish(SessionEvent.Shell.Started, {
sessionID,
shell: Shell.Info.make({
id: Shell.ID.make("sh_projector"),
@ -414,7 +415,7 @@ describe("SessionProjector", () => {
time: { started: 0 },
}),
})
yield* events.publish(SessionEvent.Shell.Ended, {
yield* bus.publish(SessionEvent.Shell.Ended, {
sessionID,
shell: Shell.Info.make({
id: Shell.ID.make("sh_projector"),
@ -429,12 +430,12 @@ describe("SessionProjector", () => {
}),
output: { output: "/project", cursor: 8, size: 8, truncated: false },
})
yield* events.publish(SessionEvent.Compaction.Started, {
yield* bus.publish(SessionEvent.Compaction.Started, {
sessionID,
reason: "manual",
recent: "recent context",
})
yield* events.publish(SessionEvent.Compaction.Delta, {
yield* bus.publish(SessionEvent.Compaction.Delta, {
sessionID,
text: "partial",
})
@ -454,7 +455,7 @@ describe("SessionProjector", () => {
.all()
.pipe(Effect.orDie),
).toEqual([{ data: expect.objectContaining({ status: "running", summary: "", recent: "recent context" }) }])
yield* events.publish(SessionEvent.Compaction.Ended, {
yield* bus.publish(SessionEvent.Compaction.Ended, {
sessionID,
reason: "manual",
text: "summary",
@ -525,7 +526,7 @@ describe("SessionProjector", () => {
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const id = SessionMessage.ID.make("msg_creator_collision")
const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } })
yield* db
@ -533,7 +534,7 @@ describe("SessionProjector", () => {
.values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data })
.run()
const exit = yield* events
const exit = yield* bus
.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: id,
@ -594,11 +595,11 @@ describe("SessionProjector", () => {
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const first = SessionMessage.ID.make("msg_retry_first")
const second = SessionMessage.ID.make("msg_retry_second")
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: build, model })
yield* events.publish(SessionEvent.RetryScheduled, {
yield* bus.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: build, model })
yield* bus.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: first,
attempt: 2,
@ -619,15 +620,15 @@ describe("SessionProjector", () => {
retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } },
})
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: build, model })
yield* events.publish(SessionEvent.RetryScheduled, {
yield* bus.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: build, model })
yield* bus.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: second,
attempt: 3,
at: 6_000,
error: { type: "provider.internal", message: "Unavailable" },
})
yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
const rows = yield* db
.select()
@ -661,7 +662,7 @@ describe("SessionProjector", () => {
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const suspended = () =>
db
.select({ timeSuspended: SessionTable.time_suspended })
@ -670,10 +671,10 @@ describe("SessionProjector", () => {
.get()
.pipe(Effect.orDie)
yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
expect((yield* suspended())?.timeSuspended).toBeNull()
yield* events.publish(SessionEvent.Execution.Started, { sessionID })
yield* bus.publish(SessionEvent.Execution.Started, { sessionID })
expect((yield* suspended())?.timeSuspended).toBeNull()
}),
)
@ -707,7 +708,7 @@ describe("SessionProjector", () => {
.run()
.pipe(Effect.orDie)
const service = yield* EventV2.Service
const service = yield* Bus.Service
const usageUpdated = yield* service
.subscribe(SessionEvent.UsageUpdated)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
@ -787,7 +788,7 @@ describe("SessionProjector", () => {
.run()
.pipe(Effect.orDie)
const service = yield* EventV2.Service
const service = yield* Bus.Service
yield* service.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),

View file

@ -6,18 +6,18 @@ import path from "path"
import { pathToFileURL } from "url"
import { eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { EventTable } from "@opencode-ai/core/event/sql"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@ -29,10 +29,10 @@ import type { LocationServices } from "@opencode-ai/core/location-services"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
const executionCalls: SessionV2.ID[] = []
const interruptCalls: SessionV2.ID[] = []
const wakeCalls: SessionV2.ID[] = []
const activeSessions = new Set<SessionV2.ID>()
const executionCalls: Session.ID[] = []
const interruptCalls: Session.ID[] = []
const wakeCalls: Session.ID[] = []
const activeSessions = new Set<Session.ID>()
const execution = Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
@ -63,14 +63,14 @@ const locations = Layer.effect(
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[SessionExecution.node, execution],
[LocationServiceMap.node, locations],
],
),
)
const sessionID = SessionV2.ID.make("ses_prompt_test")
const sessionID = Session.ID.make("ses_prompt_test")
const messageID = SessionMessage.ID.create()
const setup = Effect.gen(function* () {
@ -130,8 +130,8 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => {
SessionMessage.Assistant.make({
id,
type: "assistant",
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
agent: Agent.ID.make("build"),
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
content: [],
time: { created: DateTime.makeUnsafe(0) },
}),
@ -139,18 +139,18 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => {
return { id, session_id: sessionID, type, seq, time_created: 0, data }
}
describe("SessionV2.prompt", () => {
describe("Session.prompt", () => {
it.effect("exposes the execution registry", () =>
Effect.gen(function* () {
activeSessions.add(sessionID)
expect(Array.from(yield* (yield* SessionV2.Service).active)).toEqual([sessionID])
expect(Array.from(yield* (yield* Session.Service).active)).toEqual([sessionID])
}).pipe(Effect.ensuring(Effect.sync(() => activeSessions.clear()))),
)
it.effect("delegates execution continuation through SessionExecution", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
executionCalls.length = 0
wakeCalls.length = 0
yield* session.resume(sessionID)
@ -162,7 +162,7 @@ describe("SessionV2.prompt", () => {
it.effect("delegates process-local interruption through SessionExecution", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
interruptCalls.length = 0
yield* session.interrupt(sessionID)
@ -173,18 +173,18 @@ describe("SessionV2.prompt", () => {
it.effect("delegates interruption without requiring a recorded Session", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
interruptCalls.length = 0
yield* session.interrupt(SessionV2.ID.make("ses_missing"))
expect(interruptCalls).toEqual([SessionV2.ID.make("ses_missing")])
yield* session.interrupt(Session.ID.make("ses_missing"))
expect(interruptCalls).toEqual([Session.ID.make("ses_missing")])
}),
)
it.effect("durably admits one user message before transcript promotion", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
const message = yield* session.prompt({
sessionID,
@ -207,8 +207,8 @@ describe("SessionV2.prompt", () => {
it.effect("commits a staged revert before admitting a new prompt", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const boundary = yield* session.prompt({
@ -216,10 +216,10 @@ describe("SessionV2.prompt", () => {
text: "boundary",
resume: false,
})
yield* SessionPending.promote(db, events, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
const stale = SessionMessage.ID.make("msg_stale_assistant")
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
yield* events.publish(SessionEvent.RevertEvent.Staged, {
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
revert: { messageID: boundary.id, files: [] },
})
@ -240,16 +240,16 @@ describe("SessionV2.prompt", () => {
it.effect("holds synthetic input behind a staged revert and discards it when committed", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const boundary = yield* session.prompt({
sessionID,
text: "boundary",
resume: false,
})
yield* SessionPending.promote(db, events, sessionID, "steer")
yield* events.publish(SessionEvent.RevertEvent.Staged, {
yield* SessionPending.promote(db, bus, sessionID, "steer")
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
revert: { messageID: boundary.id, files: [] },
})
@ -269,7 +269,7 @@ describe("SessionV2.prompt", () => {
it.effect("resolves attachment MIME before admission", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
const uri =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
@ -298,7 +298,7 @@ describe("SessionV2.prompt", () => {
it.effect("materializes selected source file content", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
const directory = import.meta.dir
const source = path.join(directory, "session-prompt.test.ts")
const sourceUri = pathToFileURL(source)
@ -329,7 +329,7 @@ describe("SessionV2.prompt", () => {
it.effect("materializes directories as directory attachments", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
const uri = pathToFileURL(import.meta.dir).href
const message = yield* session.prompt({
@ -354,7 +354,7 @@ describe("SessionV2.prompt", () => {
it.effect("materializes local image content before admission", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
const directory = yield* Effect.acquireRelease(
Effect.promise(() => mkdtemp(path.join(tmpdir(), "opencode-session-prompt-"))),
(directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })),
@ -389,7 +389,7 @@ describe("SessionV2.prompt", () => {
it.effect("sniffs data URL content instead of trusting its declared MIME", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
const uri = `data:video/mp2t;base64,${Buffer.from("export const value = 1\n").toString("base64")}`
const message = yield* session.prompt({
@ -413,7 +413,7 @@ describe("SessionV2.prompt", () => {
it.effect("rejects malformed base64 data URLs", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
const uri = "data:image/png;base64,not-base64"
const error = yield* session
@ -436,19 +436,19 @@ describe("SessionV2.prompt", () => {
it.effect("streams durable Session events after an aggregate sequence", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const publicEvents = (input: { sessionID: SessionV2.ID; after?: number }) =>
const publicEvents = (input: { sessionID: Session.ID; after?: number }) =>
session
.log({ ...input, follow: true })
.pipe(Stream.filter((item): item is SessionEvent.DurableEvent => !EventV2.isSynced(item)))
.pipe(Stream.filter((item): item is SessionEvent.DurableEvent => !Bus.isSynced(item)))
const fiber = yield* publicEvents({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* session.prompt({ sessionID, text: "First", resume: false })
yield* session.prompt({ sessionID, text: "Second", resume: false })
yield* SessionPending.promote(db, events, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
const streamed = Array.from(yield* Fiber.join(fiber))
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
@ -468,7 +468,7 @@ describe("SessionV2.prompt", () => {
it.effect("resumes through a recorded message without appending another prompt", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
const message = yield* session.prompt({
sessionID,
text: "Fix the failing tests",
@ -489,7 +489,7 @@ describe("SessionV2.prompt", () => {
it.effect("records distinct messages when the ID is omitted", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
const input = { sessionID, text: "Fix the failing tests", resume: false }
const first = yield* session.prompt(input)
@ -504,7 +504,7 @@ describe("SessionV2.prompt", () => {
it.effect("returns the original recorded message when the ID is retried", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
const input = {
sessionID,
id: messageID,
@ -524,7 +524,7 @@ describe("SessionV2.prompt", () => {
it.effect("wakes execution when an exact prompt retry recovers a committed message", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
const input = {
sessionID,
id: messageID,
@ -544,7 +544,7 @@ describe("SessionV2.prompt", () => {
it.effect("rejects reuse of one ID with a different prompt", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
yield* session.prompt({
sessionID,
@ -569,7 +569,7 @@ describe("SessionV2.prompt", () => {
it.effect("rejects reuse of one ID with a different delivery mode", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
yield* session.prompt({
id: messageID,
@ -594,7 +594,7 @@ describe("SessionV2.prompt", () => {
it.effect("returns one recorded message to concurrent exact retries", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
const input = {
sessionID,
id: messageID,
@ -607,7 +607,7 @@ describe("SessionV2.prompt", () => {
expect(messages[1]).toEqual(messages[0])
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admittedCount).toBe(1)
expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
}),
)
@ -615,8 +615,8 @@ describe("SessionV2.prompt", () => {
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
yield* session.prompt({
id: messageID,
sessionID,
@ -625,11 +625,11 @@ describe("SessionV2.prompt", () => {
})
yield* Effect.all(
[SessionPending.promote(db, events, sessionID, "steer"), SessionPending.promote(db, events, sessionID, "steer")],
[SessionPending.promote(db, bus, sessionID, "steer"), SessionPending.promote(db, bus, sessionID, "steer")],
{ concurrency: "unbounded" },
)
expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputPromoted.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputPromoted.type, 1))).toBe(1)
expect(yield* admitted(messageID)).toBeUndefined()
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: messageID, type: "user", text: "Promote once" },
@ -641,8 +641,8 @@ describe("SessionV2.prompt", () => {
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
wakeCalls.length = 0
yield* session.prompt({
id: messageID,
@ -659,7 +659,7 @@ describe("SessionV2.prompt", () => {
.all()
.pipe(Effect.orDie)
yield* events.remove(sessionID)
yield* bus.remove(sessionID)
yield* db
.delete(SessionPendingTable)
.where(eq(SessionPendingTable.session_id, sessionID))
@ -670,7 +670,7 @@ describe("SessionV2.prompt", () => {
.where(eq(SessionMessageTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
yield* events.replayAll(
yield* bus.replayAll(
recorded.map((event) => ({
id: event.id,
created: DateTime.makeUnsafe(event.created),
@ -700,8 +700,8 @@ describe("SessionV2.prompt", () => {
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const session = yield* SessionV2.Service
const other = SessionV2.ID.make("ses_prompt_other")
const session = yield* Session.Service
const other = Session.ID.make("ses_prompt_other")
yield* db
.insert(SessionTable)
.values({
@ -727,7 +727,7 @@ describe("SessionV2.prompt", () => {
it.effect("rejects a prompt ID already used by visible Session history", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
const { db } = yield* Database.Service
const {
id: _,
@ -762,7 +762,7 @@ describe("SessionV2.prompt", () => {
it.effect("starts execution by default after recording the prompt", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
executionCalls.length = 0
wakeCalls.length = 0
@ -776,7 +776,7 @@ describe("SessionV2.prompt", () => {
it.effect("starts execution when resume is explicitly true", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
executionCalls.length = 0
wakeCalls.length = 0
@ -794,7 +794,7 @@ describe("SessionV2.prompt", () => {
it.effect("only records the prompt when resume is false", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
executionCalls.length = 0
wakeCalls.length = 0
@ -808,7 +808,7 @@ describe("SessionV2.prompt", () => {
it.effect("treats prompt metadata as durable retry identity", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
const input = {
id: messageID,
sessionID,
@ -830,8 +830,8 @@ describe("SessionV2.prompt", () => {
it.effect("durably admits synthetic input before transcript promotion", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const input = yield* session.synthetic({
@ -855,7 +855,7 @@ describe("SessionV2.prompt", () => {
},
})
yield* SessionPending.promote(db, events, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
expect(yield* session.messages({ sessionID })).toMatchObject([
{
@ -872,15 +872,15 @@ describe("SessionV2.prompt", () => {
it.effect("reconciles exact synthetic retries and rejects conflicting reuse", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
const database = yield* Database.Service
const input = { id: messageID, sessionID, text: "Completed", resume: false }
const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], {
concurrency: "unbounded",
})
yield* SessionPending.promote(database.db, events, sessionID, "steer")
yield* SessionPending.promote(database.db, bus, sessionID, "steer")
const promotedRetry = yield* session.synthetic(input)
const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip)
@ -888,15 +888,15 @@ describe("SessionV2.prompt", () => {
expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", data: { text: "Completed" } })
expect(failure).toMatchObject({ _tag: "Session.SyntheticConflictError", sessionID, inputID: messageID })
expect(yield* admittedCount).toBe(0)
expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
}),
)
it.effect("keeps queued input pending until the idle boundary", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const input = yield* session.synthetic({
@ -909,11 +909,11 @@ describe("SessionV2.prompt", () => {
expect(input.delivery).toBe("queue")
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(true)
expect(
yield* SessionPending.promote(db, events, sessionID, "steer"),
yield* SessionPending.promote(db, bus, sessionID, "steer"),
).toBe(0)
expect(yield* session.messages({ sessionID })).toEqual([])
expect(
yield* SessionPending.promote(db, events, sessionID, "input"),
yield* SessionPending.promote(db, bus, sessionID, "input"),
).toBe(1)
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
expect(yield* session.messages({ sessionID })).toMatchObject([
@ -925,8 +925,8 @@ describe("SessionV2.prompt", () => {
it.effect("promotes prompt and synthetic steers in admission order", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
yield* session.prompt({
@ -941,7 +941,7 @@ describe("SessionV2.prompt", () => {
resume: false,
})
yield* SessionPending.promote(db, events, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
expect(
(yield* session.messages({ sessionID, order: "asc" })).map((message) =>
@ -952,11 +952,11 @@ describe("SessionV2.prompt", () => {
)
})
describe("SessionV2.pending", () => {
describe("Session.pending", () => {
it.effect("fails for an unknown session", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
expect(yield* session.pending(SessionV2.ID.make("ses_missing")).pipe(Effect.flip)).toMatchObject({
const session = yield* Session.Service
expect(yield* session.pending(Session.ID.make("ses_missing")).pipe(Effect.flip)).toMatchObject({
_tag: "Session.NotFoundError",
})
}),
@ -965,8 +965,8 @@ describe("SessionV2.pending", () => {
it.effect("lists admitted work in admission order until promotion", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const first = yield* session.prompt({ sessionID, text: "First steer", resume: false })
@ -985,12 +985,12 @@ describe("SessionV2.pending", () => {
])
expect(
yield* SessionPending.promote(db, events, sessionID, "input"),
yield* SessionPending.promote(db, bus, sessionID, "input"),
).toBe(2)
expect(yield* session.pending(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
expect(
yield* SessionPending.promote(db, events, sessionID, "input"),
yield* SessionPending.promote(db, bus, sessionID, "input"),
).toBe(1)
expect(yield* session.pending(sessionID)).toEqual([])
}),
@ -999,7 +999,7 @@ describe("SessionV2.pending", () => {
it.effect("lists an unhandled compaction barrier until it settles", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const session = yield* Session.Service
const { db } = yield* Database.Service
const barrier = yield* session.compact({ sessionID })

View file

@ -3,40 +3,40 @@ import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { testEffect } from "./lib/effect"
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[ProjectV2.node, projects],
[Project.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
describe("SessionV2.remove", () => {
describe("Session.remove", () => {
it.effect("removes a session and its children", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const session = yield* Session.Service
const parent = yield* session.create({ location })
const child = yield* session.create({ parentID: parent.id })
@ -50,8 +50,8 @@ describe("SessionV2.remove", () => {
it.effect("fails when the session does not exist", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const sessionID = SessionV2.ID.make("ses_missing")
const session = yield* Session.Service
const sessionID = Session.ID.make("ses_missing")
expect(yield* Effect.result(session.remove(sessionID))).toMatchObject({
_tag: "Failure",

View file

@ -1,18 +1,18 @@
import { describe, expect, test } from "bun:test"
import { Message } from "@opencode-ai/ai"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentAttachment, Base64, FileAttachment } from "@opencode-ai/schema/prompt"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Agent } from "@opencode-ai/core/agent"
import { Shell } from "@opencode-ai/schema/shell"
import { DateTime } from "effect"
const created = DateTime.makeUnsafe(0)
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
const build = AgentV2.defaultID
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
const build = Agent.defaultID
describe("toLLMMessages", () => {
test("omits empty assistant turns", () => {
@ -21,7 +21,7 @@ describe("toLLMMessages", () => {
id: id(value),
type: "assistant",
agent: build,
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
content,
time: { created, completed: created },
})
@ -45,7 +45,7 @@ describe("toLLMMessages", () => {
expect(messages.map((message) => message.id)).toEqual([id("text"), id("reasoning")])
})
test("maps every top-level V2 Session message type", () => {
test("maps every top-level Session message type", () => {
const file = FileAttachment.make({
data: Base64.make("aGVsbG8="),
mime: "image/png",
@ -63,7 +63,7 @@ describe("toLLMMessages", () => {
SessionMessage.ModelSelected.make({
id: id("model"),
type: "model-switched",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
time: { created },
}),
SessionMessage.System.make({
@ -345,7 +345,7 @@ Recent work
id: id("assistant"),
type: "assistant",
agent: build,
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
content: [
SessionMessage.AssistantText.make({ type: "text", text: "Checking" }),
SessionMessage.AssistantReasoning.make({
@ -495,7 +495,7 @@ Recent work
id: id("assistant-openai-reasoning"),
type: "assistant",
agent: build,
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
@ -519,7 +519,7 @@ Recent work
})
test("replays flat state under an OpenCode hosted model's route key", () => {
const opencode = ModelV2.Ref.make({ id: ModelV2.ID.make("claude-fable-5"), providerID: ProviderV2.ID.opencode })
const opencode = Model.Ref.make({ id: Model.ID.make("claude-fable-5"), providerID: Provider.ID.opencode })
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
@ -553,7 +553,7 @@ Recent work
id: id("assistant-failed"),
type: "assistant",
agent: build,
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
@ -651,7 +651,7 @@ Recent work
id: id("assistant-old-model"),
type: "assistant",
agent: build,
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
model: { id: Model.ID.make("old-model"), providerID: Provider.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
@ -745,7 +745,7 @@ Recent work
id: id("assistant-alias"),
type: "assistant",
agent: build,
model: { id: ModelV2.ID.make("fast"), providerID: ProviderV2.ID.make("provider") },
model: { id: Model.ID.make("fast"), providerID: Provider.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
@ -756,7 +756,7 @@ Recent work
time: { created, completed: created },
}),
],
ModelV2.Ref.make({ id: ModelV2.ID.make("fast"), providerID: ProviderV2.ID.make("provider") }),
Model.Ref.make({ id: Model.ID.make("fast"), providerID: Provider.ID.make("provider") }),
)
expect(messages[0]?.content).toEqual([
@ -775,7 +775,7 @@ Recent work
id: id("assistant-phase"),
type: "assistant",
agent: build,
model: { id: ModelV2.ID.make("old"), providerID: ProviderV2.ID.make("provider") },
model: { id: Model.ID.make("old"), providerID: Provider.ID.make("provider") },
content: [
SessionMessage.AssistantText.make({
type: "text",
@ -787,7 +787,7 @@ Recent work
time: { created, completed: created },
}),
],
ModelV2.Ref.make({ id: ModelV2.ID.make("new"), providerID: ProviderV2.ID.make("provider") }),
Model.Ref.make({ id: Model.ID.make("new"), providerID: Provider.ID.make("provider") }),
)
expect(messages[0]?.content).toEqual([

View file

@ -6,16 +6,16 @@ import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Job } from "@opencode-ai/core/job"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Permission } from "@opencode-ai/core/permission"
import { Agent } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionTitle } from "@opencode-ai/core/session/title"
@ -25,8 +25,7 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator
import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Tool } from "@opencode-ai/core/tool"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Location } from "@opencode-ai/core/location"
@ -56,8 +55,8 @@ const cassette = HttpRecorder.layerFetch(cassetteName, { directory: cassetteDire
const executor = RequestExecutor.layer.pipe(Layer.provide(cassette))
const client = LLMClient.layer.pipe(Layer.provide(executor))
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
Permission.Service,
Permission.Service.of({
assert: () => Effect.die("unused"),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
@ -116,15 +115,14 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[ReferenceInstructions.node, referenceInstructions],
[McpInstructions.node, mcpInstructions],
[Config.node, config],
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Permission.node, permission],
[PluginSupervisor.node, pluginSupervisor],
])
const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const sessionRunner = yield* SessionRunner.Service
const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
const coordinator = yield* SessionRunCoordinator.make<Session.ID, SessionRunner.RunError>({
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
})
return SessionExecution.Service.of({
@ -140,13 +138,13 @@ const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
AgentV2.node,
Agent.node,
Catalog.node,
PluginHooks.node,
ToolRegistry.node,
Tool.node,
SessionRunnerModel.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
@ -155,13 +153,12 @@ const it = testEffect(
Config.node,
Snapshot.node,
SessionRunnerLLM.node,
SessionV2.node,
Session.node,
]),
[
[LayerNodePlatform.llmClient, client],
[PermissionV2.node, permission],
[Permission.node, permission],
[Catalog.node, promptCatalog],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[SessionRunnerModel.node, models],
[InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext],
@ -175,16 +172,16 @@ const it = testEffect(
],
),
)
const sessionID = SessionV2.ID.make("ses_runner_recorded")
const sessionID = Session.ID.make("ses_runner_recorded")
describe("SessionRunnerLLM recorded", () => {
it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () =>
it.effect("executes one recorded prompt through the recorded HTTP transport", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
yield* agents.transform((draft) =>
draft.update(AgentV2.ID.make("build"), (agent) => {
draft.update(Agent.ID.make("build"), (agent) => {
agent.mode = "primary"
agent.permissions.push({ action: "execute", resource: "*", effect: "deny" })
}),
@ -215,7 +212,7 @@ describe("SessionRunnerLLM recorded", () => {
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const session = yield* SessionV2.Service
const session = yield* Session.Service
const prompt = yield* session.prompt({
sessionID,
text: "Say hello in one short sentence.",

View file

@ -2,29 +2,30 @@ import { expect, test } from "bun:test"
import { Cause, Effect, Exit, Schema } from "effect"
import { LLMEvent } from "@opencode-ai/ai"
import { Money } from "@opencode-ai/schema/money"
import { EventV2 } from "@opencode-ai/core/event"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { Agent } from "@opencode-ai/core/agent"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionV2 } from "@opencode-ai/core/session"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Session } from "@opencode-ai/core/session"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { RelativePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
const sessionID = SessionV2.ID.make("ses_tool_event_test")
const sessionID = Session.ID.make("ses_tool_event_test")
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const capture = (providerMetadataKey = "anthropic", options?: { readonly interruptProgress?: boolean }) => {
const published: Array<{ readonly type: string; readonly data: unknown }> = []
const events: Pick<EventV2.Interface, "publish"> = {
const bus: Pick<Bus.Interface, "publish"> = {
publish: (definition, data) => {
const publish = Effect.sync(() => {
const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload<typeof definition>
const event = { id: Event.ID.create(), type: definition.type, data } as Event.Payload<typeof definition>
published.push({
type: definition.durable
? EventV2.versionedType(definition.type, definition.durable.version)
? Bus.versionedType(definition.type, definition.durable.version)
: definition.type,
data,
})
@ -37,12 +38,12 @@ const capture = (providerMetadataKey = "anthropic", options?: { readonly interru
}
return {
published,
publisher: createLLMEventPublisher(events, {
publisher: createLLMEventPublisher(bus, {
sessionID,
agent: AgentV2.ID.make("build"),
agent: Agent.ID.make("build"),
model: {
id: ModelV2.ID.make("model"),
providerID: ProviderV2.ID.opencode,
id: Model.ID.make("model"),
providerID: Provider.ID.opencode,
},
providerMetadataKey,
assistantMessageID: SessionMessage.ID.create(),
@ -68,7 +69,6 @@ test("local tool success serializes media base64 once through canonical content"
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(
publisher.toolExecution(call.id, call.name, {
status: "completed",
output: { type: "media", mime: "image/png" },
content: [
{ type: "text", text: "Image read successfully" },
@ -227,10 +227,7 @@ test("binary failure emits no success event", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(
publisher.toolExecution(call.id, call.name, {
status: "error",
error: { type: "tool.execution", message: "Cannot read binary file" },
}),
publisher.failTool(call.id, { type: "tool.execution", message: "Cannot read binary file" }),
)
expect(published.some((event) => event.type === "session.tool.success.2")).toBe(false)
expect(published.some((event) => event.type === "session.tool.failed.2")).toBe(true)

View file

@ -1,35 +1,16 @@
import { describe, expect } from "bun:test"
import { Tool } from "@opencode-ai/core/tool/tool"
import { AgentV2 } from "@opencode-ai/core/agent"
import type { PermissionV2 } from "@opencode-ai/core/permission"
import { Agent } from "@opencode-ai/core/agent"
import type { Permission } from "@opencode-ai/core/permission"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Image } from "@opencode-ai/core/image"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Tool } from "@opencode-ai/core/tool"
import type { Info } from "@opencode-ai/schema/tool"
import { executeTool, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { testEffect } from "./lib/effect"
const bounds: ToolOutputStore.BoundInput[] = []
const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
const outputStore = Layer.mock(ToolOutputStore.Service, {
limits: () => Effect.succeed({ maxLines: ToolOutputStore.MAX_LINES, maxBytes: ToolOutputStore.MAX_BYTES }),
bound: (input) => {
if (input.callID === "call-retention-failure") return Effect.fail(retentionFailure)
return Effect.sync(() => bounds.push(input)).pipe(
Effect.as(
input.callID === "call-bounded"
? {
content: [{ type: "text" as const, text: "bounded reference" }],
outputPaths: ["/managed/generic"],
}
: { content: input.content, outputPaths: [] },
),
)
},
})
const imageStore = Layer.mock(Image.Service, {
normalize: (resource, content) => {
if (resource === "corrupt.png") return Effect.fail(new Image.DecodeError({ resource }))
@ -48,43 +29,53 @@ const imageStore = Layer.mock(Image.Service, {
return Effect.succeed({ ...content, content: "bm9ybWFsaXplZA==", mime: "image/jpeg" })
},
})
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [
[ToolOutputStore.node, outputStore],
[Image.node, imageStore],
])
const registryLayer = AppNodeBuilder.build(Tool.node, [[Image.node, imageStore]])
const it = testEffect(registryLayer)
const identity = {
agent: AgentV2.ID.make("build"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_registry"),
}
const sessionID = SessionV2.ID.make("ses_registry")
const call = (name: string, id = `call-${name}`): ToolRegistry.ExecuteInput => ({
const sessionID = Session.ID.make("ses_registry")
const call = (name: string, id = `call-${name}`): Parameters<Tool.Snapshot["execute"]>[0] => ({
sessionID,
...identity,
call: { type: "tool-call", id, name, input: { text: name } },
})
const make = () =>
Tool.make({
const make = (): Info =>
({
name: "echo",
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ output: { text }, content: text }),
})
const constant = (text: string) =>
Tool.make({
const constant = (text: string): Info =>
({
name: "constant",
description: "Return text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: () => Effect.succeed({ output: { text }, content: text }),
})
describe("ToolRegistry", () => {
const transform = (
service: Tool.Interface,
tools: Readonly<Record<string, Info>>,
options?: Tool.Options,
) =>
service.transform((draft) =>
Object.entries(tools).forEach(([name, tool]) =>
draft.add({ ...tool, name, options: { ...tool.options, ...options } }),
),
)
describe("Tool", () => {
it.effect("rejects invalid dotted namespaces", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const error = yield* service.register({ echo: make() }, { namespace: "slack..admin" }).pipe(Effect.flip)
const service = yield* Tool.Service
const error = yield* transform(service, { echo: make() }, { namespace: "slack..admin" }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
@ -94,12 +85,11 @@ describe("ToolRegistry", () => {
it.effect("rejects invalid and colliding normalized names", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const invalid = yield* service.register({ "123": make() }, { codemode: false }).pipe(Effect.flip)
const service = yield* Tool.Service
const invalid = yield* transform(service, { "123": make() }, { codemode: false }).pipe(Effect.flip)
expect(invalid.message).toBe("Invalid tool name: 123")
const collision = yield* service
.register({ "echo.tool": make(), echo_tool: make() }, { codemode: false })
const collision = yield* transform(service, { "echo.tool": make(), echo_tool: make() }, { codemode: false })
.pipe(Effect.flip)
expect(collision.message).toBe("Duplicate normalized tool name: echo_tool")
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
@ -108,12 +98,12 @@ describe("ToolRegistry", () => {
it.effect("validates a registration batch before installing any tools", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const service = yield* Tool.Service
const error = yield* service
.registerBatch([
{ tools: { first: make() }, options: { codemode: false } },
{ tools: { second: make() }, options: { namespace: "invalid..namespace", codemode: false } },
])
.transform((draft) => {
draft.add({ ...make(), name: "first", options: { codemode: false } })
draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } })
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
@ -123,24 +113,26 @@ describe("ToolRegistry", () => {
it.effect("canonicalizes effective definitions and keeps Code Mode last", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const service = yield* Tool.Service
const tool = make()
const capture = (registrations: Parameters<typeof service.registerBatch>[0]) =>
const capture = (tools: ReadonlyArray<Info>) =>
Effect.scoped(
Effect.gen(function* () {
yield* service.registerBatch(registrations)
yield* service.transform((draft) => tools.forEach(draft.add))
return (yield* service.snapshot()).definitions
}),
)
const first = yield* capture([
{ tools: { zeta: tool, alpha: tool }, options: { codemode: false } },
{ tools: { beta: tool }, options: { namespace: "alpha", codemode: false } },
{ tools: { echo: tool } },
{ ...tool, name: "zeta", options: { codemode: false } },
{ ...tool, name: "alpha", options: { codemode: false } },
{ ...tool, name: "beta", options: { namespace: "alpha", codemode: false } },
{ ...tool, name: "echo" },
])
const second = yield* capture([
{ tools: { echo: tool } },
{ tools: { beta: tool }, options: { namespace: "alpha", codemode: false } },
{ tools: { alpha: tool, zeta: tool }, options: { codemode: false } },
{ ...tool, name: "echo" },
{ ...tool, name: "beta", options: { namespace: "alpha", codemode: false } },
{ ...tool, name: "alpha", options: { codemode: false } },
{ ...tool, name: "zeta", options: { codemode: false } },
])
expect(first).toEqual(second)
@ -150,7 +142,7 @@ describe("ToolRegistry", () => {
it.effect("keeps execute available without Code Mode tools unless explicitly denied", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const service = yield* Tool.Service
const available = yield* service.snapshot()
expect(available.definitions.map((tool) => tool.name)).toEqual(["execute"])
@ -164,10 +156,10 @@ describe("ToolRegistry", () => {
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ question: make(), bash: make() }, { codemode: false })
yield* service.register({ edit: make(), write: make() }, { codemode: false, permission: "edit" })
const names = (permissions: PermissionV2.Ruleset) =>
const service = yield* Tool.Service
yield* transform(service, { question: make(), bash: make() }, { codemode: false })
yield* transform(service, { edit: make(), write: make() }, { codemode: false, permission: "edit" })
const names = (permissions: Permission.Ruleset) =>
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
@ -198,10 +190,10 @@ describe("ToolRegistry", () => {
it.effect("keeps permission options isolated between registrations", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const service = yield* Tool.Service
const shared = make()
yield* service.register({ first: shared }, { codemode: false })
yield* service.register({ second: shared }, { codemode: false, permission: "edit" })
yield* transform(service, { first: shared }, { codemode: false })
yield* transform(service, { second: shared }, { codemode: false, permission: "edit" })
expect(
(yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map((tool) => tool.name),
@ -211,9 +203,9 @@ describe("ToolRegistry", () => {
it.effect("removes a scoped registration", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const service = yield* Tool.Service
const scope = yield* Scope.make()
yield* service.register({ echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
yield* transform(service, { echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo", "execute"])
yield* Scope.close(scope, Exit.void)
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["execute"])
@ -222,11 +214,10 @@ describe("ToolRegistry", () => {
it.effect("preserves an interrupted registration until its scope closes", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const service = yield* Tool.Service
const scope = yield* Scope.make()
const registered = yield* Deferred.make<void>()
const fiber = yield* service
.register({ echo: make() }, { codemode: false })
const fiber = yield* transform(service, { echo: make() }, { codemode: false })
.pipe(
Effect.andThen(Deferred.succeed(registered, undefined)),
Effect.andThen(Effect.never),
@ -244,14 +235,15 @@ describe("ToolRegistry", () => {
it.effect("returns model errors without swallowing interruption or defects", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register(
const service = yield* Tool.Service
yield* transform(service,
{
failed: Tool.make({
failed: ({
name: "failed",
description: "Failed",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
execute: () => Effect.fail(new Tool.Error({ message: "Denied" })),
}),
},
{ codemode: false },
@ -269,11 +261,12 @@ describe("ToolRegistry", () => {
...identity,
call: { type: "tool-call", id: "missing", name: "missing", input: {} },
}),
).toEqual({ status: "error", error: { type: "tool.unknown", message: "Unknown tool: missing" } })
).toEqual({ status: "error", error: { type: "tool.execution", message: "Unknown tool: missing" } })
yield* service.register(
yield* transform(service,
{
defect: Tool.make({
defect: ({
name: "defect",
description: "Defect",
input: Schema.Struct({}),
output: Schema.Struct({}),
@ -297,22 +290,9 @@ describe("ToolRegistry", () => {
}),
)
it.effect("propagates retention failures through execution", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() }, { codemode: false })
const toolSet = yield* service.snapshot()
const exit = yield* toolSet.execute(call("echo", "call-retention-failure")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure)
expect(retentionFailure.message).toBe("Failed to write tool output: disk full")
}),
)
it.effect("exposes execution only through a snapshot", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const service = yield* Tool.Service
expect("definitions" in service).toBe(false)
expect("execute" in service).toBe(false)
expect("settle" in service).toBe(false)
@ -322,11 +302,12 @@ describe("ToolRegistry", () => {
it.effect("passes complete call identity to tool execution", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const service = yield* Tool.Service
const contexts: Tool.Context[] = []
yield* service.register(
yield* transform(service,
{
context: Tool.make({
context: ({
name: "context",
description: "Context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
@ -341,37 +322,19 @@ describe("ToolRegistry", () => {
...identity,
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
})
expect(contexts).toEqual([{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) }])
}),
)
it.effect("encodes output and applies generic execution bounding", () =>
Effect.gen(function* () {
bounds.length = 0
const service = yield* ToolRegistry.Service
yield* service.register({ bounded: make() }, { codemode: false })
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-bounded", name: "bounded", input: { text: "complete" } },
}),
).toEqual({
status: "completed",
output: { text: "complete" },
content: [{ type: "text", text: "bounded reference" }],
outputPaths: ["/managed/generic"],
})
expect(bounds).toHaveLength(1)
expect(contexts).toEqual([
{ sessionID, ...identity, callID: Tool.CallID.make("call-context"), progress: expect.any(Function) },
])
}),
)
it.effect("normalizes image tool output at execution and drops unresizable images", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register(
const service = yield* Tool.Service
yield* transform(service,
{
snapshot: Tool.make({
snapshot: ({
name: "snapshot",
description: "Return images",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
@ -379,9 +342,14 @@ describe("ToolRegistry", () => {
Effect.succeed({
output: { text },
content: [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
{ type: "file", uri: "data:image/png;base64,aW1hZ2U=", mime: "image/png", name: "frame.png" },
{
type: "file",
uri: "data:image/png;base64,aW1hZ2U=",
mime: "image/png",
name: "too-large.png",
},
{ type: "file", uri: "data:image/png;base64,aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
{ type: "text", text },
],
}),
@ -402,10 +370,11 @@ describe("ToolRegistry", () => {
it.effect("publishes progress metadata unchanged", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register(
const service = yield* Tool.Service
yield* transform(service,
{
progressive: Tool.make({
progressive: ({
name: "progressive",
description: "Emit image progress",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
@ -416,7 +385,7 @@ describe("ToolRegistry", () => {
{ codemode: false },
)
const updates: ToolRegistry.Progress[] = []
const updates: Tool.Metadata[] = []
yield* executeTool(service, {
...call("progressive"),
progress: (update) =>
@ -430,7 +399,7 @@ describe("ToolRegistry", () => {
it.effect("enforces transformed codecs at execution and projection boundaries", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const service = yield* Tool.Service
const executed: string[] = []
const Transformed = Schema.Boolean.pipe(
Schema.decodeTo(Schema.String, {
@ -438,9 +407,10 @@ describe("ToolRegistry", () => {
encode: SchemaGetter.transform((value) => value === "yes"),
}),
)
yield* service.register(
yield* transform(service,
{
transformed: Tool.make({
transformed: ({
name: "transformed",
description: "Transform values",
input: Schema.Struct({ value: Transformed }),
output: Schema.Struct({ value: Transformed }),
@ -476,9 +446,10 @@ describe("ToolRegistry", () => {
})
expect(executed).toEqual(["yes"])
yield* service.register(
yield* transform(service,
{
invalid_output: Tool.make({
invalid_output: ({
name: "invalid_output",
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({
@ -513,12 +484,12 @@ describe("ToolRegistry", () => {
it.effect("executes the tool advertised in a model request", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const service = yield* Tool.Service
const scope = yield* Scope.make()
yield* service.register({ echo: constant("advertised") }, { codemode: false }).pipe(Scope.provide(scope))
yield* transform(service, { echo: constant("advertised") }, { codemode: false }).pipe(Scope.provide(scope))
const request = yield* service.snapshot()
yield* Scope.close(scope, Exit.void)
yield* service.register({ echo: constant("replacement") }, { codemode: false })
yield* transform(service, { echo: constant("replacement") }, { codemode: false })
expect((yield* request.execute(call("echo"))).content).toEqual([{ type: "text", text: "advertised" }])
expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "replacement" }])
@ -527,10 +498,10 @@ describe("ToolRegistry", () => {
it.effect("reveals the previous registration after an overlay closes", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: constant("base") }, { codemode: false })
const service = yield* Tool.Service
yield* transform(service, { echo: constant("base") }, { codemode: false })
const overlay = yield* Scope.make()
yield* service.register({ echo: constant("overlay") }, { codemode: false }).pipe(Scope.provide(overlay))
yield* transform(service, { echo: constant("overlay") }, { codemode: false }).pipe(Scope.provide(overlay))
expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "overlay" }])
yield* Scope.close(overlay, Exit.void)
@ -540,12 +511,12 @@ describe("ToolRegistry", () => {
it.effect("executes and reports progress for codemode tools advertised in a model request", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const service = yield* Tool.Service
const executed: string[] = []
const scope = yield* Scope.make()
yield* service
.register({
echo: Tool.make({
yield* transform(service, {
echo: ({
name: "echo",
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
@ -563,8 +534,9 @@ describe("ToolRegistry", () => {
expect(execute?.description).toContain("confined Code Mode runtime")
expect(execute?.description).not.toContain("Echo text")
yield* Scope.close(scope, Exit.void)
yield* service.register({
echo: Tool.make({
yield* transform(service, {
echo: ({
name: "echo",
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
@ -572,7 +544,7 @@ describe("ToolRegistry", () => {
}),
})
const progress: ToolRegistry.Progress[] = []
const progress: Tool.Metadata[] = []
const execution = yield* toolSet.execute({
...call("execute"),
call: {
@ -584,10 +556,11 @@ describe("ToolRegistry", () => {
progress: (update) => Effect.sync(() => progress.push(update)),
})
expect(execution).toMatchObject({ status: "completed", content: [{ type: "text" }] })
expect(execution).toMatchObject({ content: [{ type: "text" }] })
expect(executed).toEqual(["old:request"])
expect(progress).toEqual([
{ toolCalls: [{ tool: "echo", status: "running", input: { text: "request" } }] },
{ stage: "old" },
{ toolCalls: [{ tool: "echo", status: "completed", input: { text: "request" } }] },
])
}),

File diff suppressed because it is too large Load diff

View file

@ -4,30 +4,30 @@ import { Effect, Layer, LayerMap } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SkillV2 } from "@opencode-ai/core/skill"
import { Skill } from "@opencode-ai/core/skill"
import { testEffect } from "./lib/effect"
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const projects = Layer.mock(ProjectV2.Service, {
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
const projects = Layer.mock(Project.Service, {
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
})
const skills = Layer.mock(SkillV2.Service, {
const skills = Layer.mock(Skill.Service, {
list: () =>
Effect.succeed([
SkillV2.Info.make({
id: SkillV2.ID.make("effect"),
name: SkillV2.Name.make("Effect"),
Skill.Info.make({
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
description: "Effect guidance",
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
content: "Use Effect",
@ -45,23 +45,23 @@ const locations = Layer.effect(
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[LocationServiceMap.node, locations],
[ProjectV2.node, projects],
[Project.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
describe("SessionV2.skill", () => {
describe("Session.skill", () => {
it.effect("projects the caller-supplied message ID", () =>
Effect.gen(function* () {
const sessions = yield* SessionV2.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ location })
const id = SessionMessage.ID.make("msg_caller_skill")
yield* sessions.skill({ id, sessionID: session.id, skill: SkillV2.ID.make("effect"), resume: false })
yield* sessions.skill({ id, sessionID: session.id, skill: Skill.ID.make("effect"), resume: false })
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
expect.objectContaining({ id, type: "skill", skill: "effect", name: "Effect", text: "Use Effect" }),

View file

@ -1,12 +1,12 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Agent } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@ -14,7 +14,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTitle } from "@opencode-ai/core/session/title"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
@ -77,10 +77,10 @@ const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
AgentV2.node,
Agent.node,
SessionTitle.node,
]),
[
@ -90,7 +90,7 @@ const it = testEffect(
),
)
const insertSession = (id: SessionV2.ID) =>
const insertSession = (id: Session.ID) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
@ -114,16 +114,16 @@ const insertSession = (id: SessionV2.ID) =>
.pipe(Effect.orDie)
})
const prompt = (sessionID: SessionV2.ID, text: string) =>
const prompt = (sessionID: Session.ID, text: string) =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const messageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.InputAdmitted, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID,
inputID: messageID,
input: { type: "user", data: { text }, delivery: "steer" },
})
yield* events.publish(SessionEvent.InputPromoted, {
yield* bus.publish(SessionEvent.InputPromoted, {
sessionID,
inputID: messageID,
})
@ -132,15 +132,15 @@ const prompt = (sessionID: SessionV2.ID, text: string) =>
it.effect("generates a title from the sole user message and renames the session", () =>
Effect.gen(function* () {
requests = []
const agentService = yield* AgentV2.Service
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(AgentV2.ID.make("title"), (agent) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = SessionV2.ID.make("ses_title_generate")
const sessionID = Session.ID.make("ses_title_generate")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
@ -171,15 +171,15 @@ it.effect("generates a title from the sole user message and renames the session"
it.effect("does not generate once a second user message exists", () =>
Effect.gen(function* () {
requests = []
const agentService = yield* AgentV2.Service
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(AgentV2.ID.make("title"), (agent) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = SessionV2.ID.make("ses_title_second_message")
const sessionID = Session.ID.make("ses_title_second_message")
yield* insertSession(sessionID)
yield* prompt(sessionID, "First message")
yield* prompt(sessionID, "Second message")
@ -200,15 +200,15 @@ it.effect("does not generate once a second user message exists", () =>
it.effect("does not generate for a child session", () =>
Effect.gen(function* () {
requests = []
const agentService = yield* AgentV2.Service
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(AgentV2.ID.make("title"), (agent) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = SessionV2.ID.make("ses_title_child")
const sessionID = Session.ID.make("ses_title_child")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
@ -221,7 +221,7 @@ it.effect("does not generate for a child session", () =>
.values({
id: sessionID,
project_id: Project.ID.global,
parent_id: SessionV2.ID.make("ses_title_parent"),
parent_id: Session.ID.make("ses_title_parent"),
slug: sessionID,
directory: "/project",
title: "Child session - fake",
@ -246,7 +246,7 @@ it.effect("does not generate for a child session", () =>
it.effect("does not generate when the title agent is removed", () =>
Effect.gen(function* () {
requests = []
const sessionID = SessionV2.ID.make("ses_title_no_agent")
const sessionID = Session.ID.make("ses_title_no_agent")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")

View file

@ -3,33 +3,33 @@ import { asc, eq } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Agent } from "@opencode-ai/core/agent"
import { EventTable } from "@opencode-ai/core/event/sql"
import { ModelV2 } from "@opencode-ai/core/model"
import { Model } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(LayerNode.group([Database.node, EventV2.node, SessionProjector.node])))
const it = testEffect(LayerNode.compile(LayerNode.group([Database.node, Bus.node, SessionProjector.node])))
const timestamp = DateTime.makeUnsafe(1)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
const model = { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }
const content = (text: string) => [{ type: "text" as const, text }] as const
describe("Tool.Progress", () => {
describe("Tool.Metadata", () => {
it.effect("keeps progress live-only and terminal settlements durable", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
const service = yield* EventV2.Service
const sessionID = SessionV2.ID.make("ses_tool_progress_projector")
const service = yield* Bus.Service
const sessionID = Session.ID.make("ses_tool_progress_projector")
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
@ -52,7 +52,7 @@ describe("Tool.Progress", () => {
yield* service.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
agent: AgentV2.ID.make("build"),
agent: Agent.ID.make("build"),
model,
})
const readAssistant = Effect.gen(function* () {
@ -144,9 +144,9 @@ describe("Tool.Progress", () => {
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
expect(rows.map((row) => row.type)).not.toContain(EventV2.versionedType(SessionEvent.Tool.Progress.type, 1))
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Success.type, 2))
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Failed.type, 2))
expect(rows.map((row) => row.type)).not.toContain(Bus.versionedType(SessionEvent.Tool.Progress.type, 1))
expect(rows.map((row) => row.type)).toContain(Bus.versionedType(SessionEvent.Tool.Success.type, 2))
expect(rows.map((row) => row.type)).toContain(Bus.versionedType(SessionEvent.Tool.Failed.type, 2))
}),
)
})

View file

@ -3,40 +3,40 @@ import { Effect, Layer } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@opencode-ai/core/bus"
import { Job } from "@opencode-ai/core/job"
import { Location } from "@opencode-ai/core/location"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Session } from "@opencode-ai/core/session"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionStore } from "@opencode-ai/core/session/store"
import { testEffect } from "./lib/effect"
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const awaited: SessionV2.ID[] = []
const projects = Layer.mock(ProjectV2.Service, {
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
const awaited: Session.ID[] = []
const projects = Layer.mock(Project.Service, {
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
})
const execution = Layer.mock(SessionExecution.Service, {
awaitIdle: (sessionID) => Effect.sync(() => awaited.push(sessionID)),
})
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[ProjectV2.node, projects],
[Project.node, projects],
[SessionExecution.node, execution],
],
),
)
describe("SessionV2.wait", () => {
describe("Session.wait", () => {
it.effect("delegates to SessionExecution.awaitIdle", () =>
Effect.gen(function* () {
awaited.length = 0
const sessions = yield* SessionV2.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ location })
yield* sessions.wait(session.id)

Some files were not shown because too many files have changed in this diff Show more