Merge remote-tracking branch 'origin/v2' into nxl/vcs-plugin-api

# Conflicts:
#	packages/core/src/config.ts
#	packages/core/src/plugin/internal.ts
#	packages/core/src/project.ts
#	packages/core/test/project.test.ts
#	packages/plugin/src/v2/effect/context.ts
#	packages/plugin/src/v2/effect/index.ts
This commit is contained in:
Shoubhit Dash 2026-07-06 18:06:51 +05:30
commit 8ede176244
599 changed files with 83198 additions and 11394 deletions

View file

@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { Policy } from "@opencode-ai/core/policy"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
@ -24,7 +23,7 @@ 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, Policy.node]),
LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node]),
[[Location.node, locationLayer]],
)
const it = testEffect(catalogLayer)
@ -333,21 +332,4 @@ describe("CatalogV2", () => {
expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
}),
)
it.effect("removes providers denied by policy after loading", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const policy = yield* Policy.Service
const providerID = ProviderV2.ID.make("blocked")
yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })])
yield* catalog.transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("model"), () => {})
})
expect(yield* catalog.provider.all()).toEqual([])
expect(yield* catalog.model.all()).toEqual([])
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
}),
)
})

View file

@ -1,13 +1,15 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Effect, PubSub, Schema, Stream } from "effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { CommandV2 } from "@opencode-ai/core/command"
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/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { ModelV2 } from "@opencode-ai/core/model"
@ -19,7 +21,7 @@ import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]), [
AppNodeBuilder.build(LayerNode.group([CommandV2.node, EventV2.node, FSUtil.node]), [
[MCP.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
@ -53,7 +55,19 @@ Review files`,
})
const command = yield* CommandV2.Service
yield* ConfigCommandPlugin.Plugin.effect(host({ command: { ...command, reload: command.reload } })).pipe(
const events = yield* EventV2.Service
const update = yield* events.publish(ConfigSchema.Event.Updated, {})
const updates = yield* PubSub.unbounded<typeof update>()
yield* ConfigCommandPlugin.Plugin.effect(
host({
command: {
list: () => Effect.die("unused command.list"),
transform: command.transform,
reload: command.reload,
},
event: { subscribe: () => Stream.fromPubSub(updates) },
}),
).pipe(
Effect.provideService(
Config.Service,
Config.Service.of({
@ -85,6 +99,15 @@ Review files`,
CommandV2.Info.make({ name: "empty", template: "" }),
CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }),
])
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again"))
yield* Effect.sleep("10 millis")
yield* PubSub.publish(updates, update)
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* command.get("review"))?.template === "Review again") break
yield* Effect.sleep("10 millis")
}
expect((yield* command.get("review"))?.template).toBe("Review again")
}),
),
),

View file

@ -1,18 +1,20 @@
import path from "path"
import fs from "fs/promises"
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { ConfigProvider } from "@opencode-ai/core/config/provider"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { EventV2 } from "@opencode-ai/core/event"
import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
import { Policy } from "@opencode-ai/core/policy"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
@ -26,6 +28,7 @@ function testLayer(
globalDirectory = path.join(directory, "global"),
projectDirectory = directory,
vcs?: Project.Vcs,
watcher?: Layer.Layer<Watcher.Service>,
) {
const locationLayer = Layer.succeed(
Location.Service,
@ -36,9 +39,10 @@ function testLayer(
),
),
)
return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [
return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: globalDirectory })],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
])
}
@ -52,6 +56,52 @@ const provider = {
}
describe("Config", () => {
it.live("reloads external config and publishes directory updates", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const file = path.join(global, "opencode.json")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(file, JSON.stringify({ shell: "first" }))
})
const updates = yield* PubSub.unbounded<Watcher.Update>()
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: () => Stream.fromPubSub(updates),
}),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const events = yield* EventV2.Service
const changed = yield* events
.subscribe(ConfigSchema.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.sleep("10 millis")
yield* PubSub.publish(updates, {
type: "update",
path: path.join(global, "commands", "review.md"),
} satisfies Watcher.Update)
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
yield* PubSub.publish(updates, { type: "update", path: file } satisfies Watcher.Update)
expect(yield* Fiber.join(changed)).toHaveLength(1)
expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
}).pipe(Effect.provide(testLayer(project, global, project, undefined, watcher)))
}),
),
),
)
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
Effect.sync(() => {
const entries = [
@ -242,6 +292,72 @@ describe("Config", () => {
),
)
it.live("substitutes environment variables and relative file contents", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = {
token: process.env.OPENCODE_TEST_MCP_TOKEN,
missing: process.env.OPENCODE_TEST_MISSING,
}
process.env.OPENCODE_TEST_MCP_TOKEN = "secret"
delete process.env.OPENCODE_TEST_MISSING
return previous
}),
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'),
fs.writeFile(
path.join(tmp.path, "opencode.jsonc"),
`{
// Ignored reference: {file:missing.txt}
"username": "user-{env:OPENCODE_TEST_MISSING}",
"mcp": {
"servers": {
"remote": {
"type": "remote",
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
"X-Token": "{file:token.txt}"
}
}
}
}
}`,
),
]),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const document = (yield* config.entries()).find((entry) => entry.type === "document")
expect(document?.info.username).toBe("user-")
const remote = document?.info.mcp?.servers?.remote
expect(remote?.type).toBe("remote")
if (remote?.type !== "remote") return
expect(remote.headers).toEqual({
Authorization: "Bearer secret",
"X-Token": 'file\n"token"',
})
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
(previous) =>
Effect.sync(() => {
if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN
else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token
if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING
else process.env.OPENCODE_TEST_MISSING = previous.missing
}),
),
)
it.live("does not load legacy config.json files", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@ -274,7 +390,6 @@ describe("Config", () => {
const file = path.join(tmp.path, "opencode.json")
const contents = JSON.stringify({
shell: "/bin/zsh",
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
providers: { local: provider },
})
yield* Effect.promise(() => fs.writeFile(file, contents))
@ -285,11 +400,6 @@ describe("Config", () => {
expect(documents[0]?.info.$schema).toBeUndefined()
expect(documents[0]?.info.shell).toBe("/bin/zsh")
expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({
effect: "deny",
action: "provider.use",
resource: "openai",
})
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
@ -723,40 +833,6 @@ describe("Config", () => {
),
)
it.live("loads policy statements in reverse config order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.writeFile(
path.join(global, "opencode.json"),
JSON.stringify({
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
}),
)
await fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] },
}),
)
})
return yield* Effect.gen(function* () {
const policy = yield* Policy.Service
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}).pipe(Effect.provide(testLayer(tmp.path, global)))
})
}),
),
)
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),

View file

@ -1,252 +1,298 @@
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { define } from "@opencode-ai/plugin/v2/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 { Config } from "@opencode-ai/core/config"
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/core/npm"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
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 { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const decode = Schema.decodeUnknownSync(Config.Info)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])),
)
describe("ConfigExternalPlugin", () => {
it.live("resolves and loads a configured Promise plugin with options", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const host = yield* PluginHost.make(plugins)
const document = path.join(import.meta.dir, "opencode.json")
describe("PluginSupervisor config", () => {
it.live("applies selectors in order", () =>
withLocation(
{ plugins: ["-opencode.provider.*", "opencode.provider.openai"] },
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
yield* ready()
expect(
(yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")),
).toEqual([Plugin.ID.make("opencode.provider.openai")])
}),
),
)
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
Effect.provideService(PluginV2.Service, plugins),
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(Location.Service, location),
Effect.provideService(Npm.Service, npm),
Effect.provideService(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
path: document,
info: decode({
plugins: [
{
package: "../plugin/fixtures/config-promise-plugin.ts",
options: { description: "Loaded from config" },
},
],
}),
}),
]),
it.live("loads configured Promise plugins with options", () =>
withLocation(
{
plugins: [
"-*",
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
options: { description: "Loaded from config" },
},
],
},
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
expect(yield* agents.get(AgentV2.ID.make("configured"))).toMatchObject({
description: "Loaded from config",
mode: "subagent",
})
}),
),
)
it.live("loads configured Effect plugins with options", () =>
withLocation(
{
plugins: [
"-*",
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-effect-plugin.ts"),
options: { description: "Effect plugin from config" },
},
],
},
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
expect(yield* agents.get(AgentV2.ID.make("effect-configured"))).toMatchObject({
description: "Effect plugin from config",
mode: "subagent",
})
}),
),
)
it.live("ignores invalid packages and continues loading", () =>
withLocation(
{
plugins: [
"-*",
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
options: { description: "Loaded after invalid plugins" },
},
],
},
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
expect(yield* agents.get(AgentV2.ID.make("configured"))).toMatchObject({
description: "Loaded after invalid plugins",
})
}),
),
)
it.live("loads auto-discovered plugin files and packages", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
expect(yield* agents.get(AgentV2.ID.make("directory"))).toMatchObject({
description: "Loaded from plugin directory",
})
expect(yield* agents.get(AgentV2.ID.make("folder"))).toMatchObject({
description: "Loaded from plugin folder",
})
}),
true,
),
)
it.live("reloads an auto-discovered plugin when its file changes", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
const events = yield* EventV2.Service
const location = yield* Location.Service
const plugins = yield* PluginV2.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")
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* 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"
}),
),
)
)
}),
false,
async (directory) => {
const plugin = path.join(directory, ".opencode", "plugin")
await fs.mkdir(plugin, { recursive: true })
await fs.writeFile(path.join(plugin, "mutable.ts"), mutablePlugin("first"))
},
),
)
expect(yield* waitForAgent(agents, "configured")).toMatchObject({
description: "Loaded from config",
mode: "subagent",
})
it.live("applies explicit removals after auto-discovery", () =>
withLocation(
{ plugins: ["-*"] },
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
expect(yield* agents.get(AgentV2.ID.make("directory"))).toBeUndefined()
expect(yield* agents.get(AgentV2.ID.make("folder"))).toBeUndefined()
}),
true,
),
)
it.live("loads user plugins before internal post plugins", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
yield* withLocation(
{
plugins: [
path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"),
],
},
Effect.gen(function* () {
yield* ready()
const registry = yield* PluginV2.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"))
expect(ids.indexOf("config-promise-plugin")).toBeLessThan(ids.indexOf("variant-source"))
expect(ids.indexOf("variant-source")).toBeLessThan(ids.indexOf("opencode.config.provider"))
expect(ids.indexOf("opencode.config.provider")).toBeLessThan(ids.indexOf("opencode.variant"))
const catalog = yield* Catalog.Service
expect(
(yield* catalog.model.get(ProviderV2.ID.make("configured"), ModelV2.ID.make("glm-5.2")))?.variants,
).toEqual([
expect.objectContaining({ id: "high", headers: { custom: "true" } }),
expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
])
}),
)
}),
)
it.live("loads a configured Effect plugin with options", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const host = yield* PluginHost.make(plugins)
it.live("allows variant generation to be disabled", () =>
withLocation(
{
plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), "-opencode.variant"],
},
Effect.gen(function* () {
yield* ready()
const registry = yield* PluginV2.Service
expect((yield* registry.list()).map((plugin) => String(plugin.id))).not.toContain("opencode.variant")
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
Effect.provideService(PluginV2.Service, plugins),
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(Location.Service, location),
Effect.provideService(Npm.Service, npm),
Effect.provideService(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
path: path.join(import.meta.dir, "opencode.json"),
info: decode({
plugins: [
{
package: "../plugin/fixtures/config-effect-plugin.ts",
options: { description: "Effect plugin from config" },
},
],
}),
}),
]),
}),
),
)
expect(yield* waitForAgent(agents, "effect-configured")).toMatchObject({
description: "Effect plugin from config",
mode: "subagent",
})
}),
)
it.live("ignores invalid plugins and continues loading", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const host = yield* PluginHost.make(plugins)
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
Effect.provideService(PluginV2.Service, plugins),
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(Location.Service, location),
Effect.provideService(Npm.Service, npm),
Effect.provideService(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
path: path.join(import.meta.dir, "opencode.json"),
info: decode({
plugins: [
"../plugin/fixtures/missing-plugin.ts",
"../plugin/fixtures/invalid-plugin.ts",
{
package: "../plugin/fixtures/config-promise-plugin.ts",
options: { description: "Loaded after invalid plugins" },
},
],
}),
}),
]),
}),
),
)
expect(yield* waitForAgent(agents, "configured")).toMatchObject({
description: "Loaded after invalid plugins",
})
}),
)
it.live("installs and resolves npm plugin packages", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const host = yield* PluginHost.make(plugins)
let installed: string | undefined
const npm = Npm.Service.of({
add: (spec) =>
Effect.sync(() => {
installed = spec
return {
directory: import.meta.dir,
entrypoint: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
}
}),
install: () => Effect.void,
which: () => Effect.succeed(undefined),
})
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
Effect.provideService(PluginV2.Service, plugins),
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(Location.Service, location),
Effect.provideService(Npm.Service, npm),
Effect.provideService(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
plugins: [
{
package: "example-plugin@1.0.0",
options: { description: "Installed from npm" },
},
],
}),
}),
]),
}),
),
)
expect(yield* waitForAgent(agents, "configured")).toMatchObject({
description: "Installed from npm",
})
expect(installed).toBe("example-plugin@1.0.0")
}),
)
it.live("loads plugin files from config directories", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const host = yield* PluginHost.make(plugins)
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
Effect.provideService(PluginV2.Service, plugins),
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(Location.Service, location),
Effect.provideService(Npm.Service, npm),
Effect.provideService(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Directory({
type: "directory",
path: AbsolutePath.make(path.join(import.meta.dir, "fixtures")),
}),
]),
}),
),
)
expect(yield* waitForAgent(agents, "directory")).toMatchObject({
description: "Loaded from plugin directory",
mode: "subagent",
})
expect(yield* waitForAgent(agents, "folder")).toMatchObject({
description: "Loaded from plugin folder",
mode: "subagent",
})
}),
const catalog = yield* Catalog.Service
expect(
(yield* catalog.model.get(ProviderV2.ID.make("configured"), ModelV2.ID.make("glm-5.2")))?.variants,
).toEqual([expect.objectContaining({ id: "high", headers: { custom: "true" } })])
}),
),
)
})
const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) {
for (let attempt = 0; attempt < 100; attempt++) {
const agent = yield* agents.get(AgentV2.ID.make(id))
if (agent) return agent
const ready = Effect.fnUntraced(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.ready
})
function withLocation<A, E, R>(
config: unknown,
effect: Effect.Effect<A, E, R>,
fixtures = false,
prepare?: (directory: string) => Promise<void>,
) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.tap((tmp) =>
Effect.promise(async () => {
await prepare?.(tmp.path)
if (fixtures) {
const directory = path.join(tmp.path, ".opencode")
await fs.mkdir(directory, { recursive: true })
await Promise.all(
["plugin", "plugins"].map((name) =>
fs.symlink(path.join(import.meta.dir, "fixtures", name), path.join(directory, name), "dir"),
),
)
}
if (config !== undefined) {
const directory = fixtures ? path.join(tmp.path, ".opencode") : tmp.path
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify(config))
}
}),
),
Effect.flatMap((tmp) =>
effect.pipe(
Effect.scoped,
Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))),
),
),
)
}
function mutablePlugin(description: string) {
const plugin = pathToFileURL(path.join(import.meta.dir, "../../../plugin/src/v2/promise/index.ts")).href
return `
import { define } from ${JSON.stringify(plugin)}
export default define({
id: "mutable-plugin",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("mutable", (agent) => {
agent.description = ${JSON.stringify(description)}
agent.mode = "subagent"
})
})
},
})
`
}
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
for (let attempt = 0; attempt < 200; attempt++) {
if (yield* condition) return
yield* Effect.sleep("10 millis")
}
return yield* Effect.die(`Timed out waiting for agent ${id}`)
return yield* Effect.die("Timed out waiting for plugin reload")
})

View file

@ -0,0 +1,108 @@
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 { Catalog } from "@opencode-ai/core/catalog"
import { CommandV2 } 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 { Global } from "@opencode-ai/core/global"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Reference } from "@opencode-ai/core/reference"
import { SkillV2 } from "@opencode-ai/core/skill"
import { Effect, Schema } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const decode = Schema.decodeUnknownSync(Config.Info)
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 catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const events = yield* EventV2.Service
const plugins = yield* PluginV2.Service
const references = yield* Reference.Service
const skills = yield* SkillV2.Service
const host = yield* PluginHost.make(plugins)
let entries: Config.Entry[] = [config("first")]
const service = Config.Service.of({ entries: () => Effect.sync(() => entries) })
const setup = <R>(effect: Effect.Effect<void, never, R>) =>
effect.pipe(Effect.provideService(Config.Service, service))
yield* setup(ConfigAgentPlugin.Plugin.effect(host))
yield* setup(ConfigCommandPlugin.Plugin.effect(host))
yield* setup(ConfigSkillPlugin.Plugin.effect(host))
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* 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()
entries = [config("second")]
yield* events.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* 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
)
}),
)
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
).toBe(false)
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
).toBe(true)
}).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))),
)
})
function config(name: string) {
return new Config.Document({
type: "document",
path: document,
info: decode({
agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } },
commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } },
skills: [`/skills/${name}`],
references: { [name]: `/references/${name}` },
providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } },
}),
})
}
function title(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
for (let attempt = 0; attempt < 100; attempt++) {
if (yield* condition) return
yield* Effect.sleep("10 millis")
}
return yield* Effect.die("Timed out waiting for config plugin reloads")
})

View file

@ -36,7 +36,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
yield* ConfigSkillPlugin.Plugin.effect(
host({
skill: { transform, reload: () => Effect.void },
skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void },
}),
).pipe(
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })),

View file

@ -255,7 +255,7 @@ describe("DatabaseMigration", () => {
)
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 9)`)
yield* db.run(
sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('event', 'session', 9, 'session.updated.1', '{}')`,
sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`,
)
yield* db.run(
sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`,

View file

@ -77,6 +77,7 @@ describe("node build", () => {
Effect.sync(() => {
acquisitions++
return Project.Service.of({
list: () => Effect.succeed([]),
directories: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
commit: () => Effect.void,

View file

@ -52,6 +52,42 @@ describe("resource", () => {
})
})
test("falls back to local logging when OTLP initialization fails", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-observability-test-"))
await using _ = {
async [Symbol.asyncDispose]() {
await fs.rm(dir, { recursive: true, force: true })
},
}
const child = Bun.spawn(
[
process.execPath,
"--eval",
`
import { Effect } from "effect"
import { Observability } from "./src/observability.ts"
await Effect.void.pipe(Effect.provide(Observability.layer), Effect.scoped, Effect.runPromise)
`,
],
{
cwd: path.join(import.meta.dir, "../.."),
env: {
...process.env,
OTEL_EXPORTER_OTLP_ENDPOINT: "://invalid",
XDG_CACHE_HOME: path.join(dir, "cache"),
XDG_CONFIG_HOME: path.join(dir, "config"),
XDG_DATA_HOME: path.join(dir, "data"),
XDG_STATE_HOME: path.join(dir, "state"),
},
stdout: "ignore",
stderr: "pipe",
},
)
const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" })
})
test("file logger appends concurrent runs with a run on every line", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-log-test-"))
await using _ = {

View file

@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Logger } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventLogger } from "@opencode-ai/core/event-logger"
import { Agent } from "@opencode-ai/schema/agent"
import { Catalog } from "@opencode-ai/schema/catalog"
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: {} })
describe("EventLogger", () => {
test("logs explicitly listed updated events", async () => {
const output = new Array<ReturnType<typeof Logger.formatStructured.log>>()
const logger = Logger.map(Logger.formatStructured, (entry) => {
output.push(entry)
})
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, {})
}).pipe(
Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, EventLogger.node]))),
Effect.provide(Logger.layer([logger])),
Effect.scoped,
Effect.runPromise,
)
expect(output.map((entry) => entry.message)).toEqual([
["event", { event: expect.objectContaining({ type: "agent.updated" }) }],
["event", { event: expect.objectContaining({ type: "catalog.updated" }) }],
["event", { event: expect.objectContaining({ type: "command.updated" }) }],
["event", { event: expect.objectContaining({ type: "config.updated" }) }],
])
})
})

View file

@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Event } from "@opencode-ai/schema/event"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
@ -22,14 +24,14 @@ const locationLayer = Layer.succeed(
location({ directory: AbsolutePath.make("project"), workspaceID: WorkspaceV2.ID.make("wrk_test") }),
),
)
const Message = EventV2.define({
const Message = EventV2.ephemeral({
type: "test.message",
schema: {
text: Schema.String,
},
})
const SyncMessage = EventV2.define({
const SyncMessage = EventV2.durable({
type: "test.sync",
durable: {
version: 1,
@ -41,7 +43,7 @@ const SyncMessage = EventV2.define({
},
})
const SyncSent = EventV2.define({
const SyncSent = EventV2.durable({
type: "test.sent",
durable: {
version: 1,
@ -53,14 +55,14 @@ const SyncSent = EventV2.define({
},
})
const GlobalMessage = EventV2.define({
const GlobalMessage = EventV2.ephemeral({
type: "test.global",
schema: {
text: Schema.String,
},
})
const VersionedMessage = EventV2.define({
const VersionedMessage = EventV2.durable({
type: "test.versioned",
durable: {
version: 2,
@ -129,12 +131,12 @@ describe("EventV2", () => {
it.effect("selects the latest durable definition independent of declaration order", () =>
Effect.sync(() => {
const latest = EventV2.define({
const latest = EventV2.durable({
type: "test.out-of-order",
durable: { version: 2, aggregate: "id" },
schema: { id: Schema.String },
})
const historical = EventV2.define({
const historical = EventV2.durable({
type: "test.out-of-order",
durable: { version: 1, aggregate: "id" },
schema: { id: Schema.String },
@ -329,8 +331,8 @@ describe("EventV2", () => {
const events = yield* EventV2.Service
const consuming = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const slowStream = yield* EventV2.liveBounded(events, 1)
const fastStream = yield* EventV2.liveBounded(events, 8)
const slowStream = yield* EventV2.liveBounded(events, { capacity: 1 })
const fastStream = yield* EventV2.liveBounded(events, { capacity: 8 })
const slow = yield* slowStream.pipe(
Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))),
Effect.forkScoped,
@ -355,6 +357,20 @@ describe("EventV2", () => {
}),
)
it.effect("filters internal events before they enter a bounded server stream", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const stream = yield* EventV2.liveBounded(events, { capacity: 1, accept: EventManifest.isServer })
const received = yield* stream.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* events.publish(McpEvent.ToolsChanged, { server: "one" })
yield* events.publish(McpEvent.ToolsChanged, { server: "two" })
const published = yield* events.publish(McpEvent.StatusChanged, { server: "example" })
expect(Array.from(yield* Fiber.join(received))).toEqual([published])
}),
)
it.effect("preserves observer interruption", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
@ -554,6 +570,7 @@ describe("EventV2", () => {
yield* events.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -573,6 +590,7 @@ describe("EventV2", () => {
yield* events.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -609,6 +627,7 @@ describe("EventV2", () => {
const exit = yield* events
.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID: envelopeAggregateID,
@ -642,6 +661,7 @@ describe("EventV2", () => {
yield* events.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -650,6 +670,7 @@ describe("EventV2", () => {
const exit = yield* events
.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 5,
aggregateID,
@ -674,13 +695,14 @@ describe("EventV2", () => {
yield* events.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(SessionEvent.ContextUpdated.type, 1),
seq: 0,
aggregateID,
data: { sessionID: aggregateID, messageID: "msg_context", timestamp: 0, text: "context" },
data: { sessionID: aggregateID, text: "context" },
})
expect(received[0]?.data.timestamp).toEqual(DateTime.makeUnsafe(0))
expect(received[0]?.created).toEqual(DateTime.makeUnsafe(0))
}),
)
@ -690,6 +712,7 @@ describe("EventV2", () => {
const exit = yield* events
.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: "unknown.event.1",
seq: 0,
aggregateID: EventV2.ID.create(),
@ -708,6 +731,7 @@ describe("EventV2", () => {
const source = yield* events.replayAll([
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -715,6 +739,7 @@ describe("EventV2", () => {
},
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -735,6 +760,7 @@ describe("EventV2", () => {
const one = yield* events.replayAll([
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -742,6 +768,7 @@ describe("EventV2", () => {
},
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -751,6 +778,7 @@ describe("EventV2", () => {
const two = yield* events.replayAll([
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 2,
aggregateID,
@ -758,6 +786,7 @@ describe("EventV2", () => {
},
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 3,
aggregateID,
@ -793,6 +822,7 @@ describe("EventV2", () => {
yield* events.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -812,6 +842,7 @@ describe("EventV2", () => {
const id = EventV2.ID.create()
const replayed = {
id,
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -833,6 +864,7 @@ describe("EventV2", () => {
const published = yield* events.publish(DurableMessage, durableData(aggregateID, "owned"))
const replayed = {
id: published.id,
created: published.created,
type: EventV2.versionedType(DurableMessage.type, 1),
seq: published.durable!.seq,
aggregateID,
@ -867,6 +899,7 @@ describe("EventV2", () => {
yield* events.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -895,6 +928,7 @@ describe("EventV2", () => {
yield* events.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -905,6 +939,7 @@ describe("EventV2", () => {
yield* events.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 2,
aggregateID,
@ -937,6 +972,7 @@ describe("EventV2", () => {
yield* events.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -949,6 +985,7 @@ describe("EventV2", () => {
.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -970,6 +1007,7 @@ describe("EventV2", () => {
yield* events.listen((event) => Effect.sync(() => received.push(event)))
const replayed = {
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -990,6 +1028,7 @@ describe("EventV2", () => {
const aggregateID = Session.ID.create()
const replayed = {
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -1014,6 +1053,7 @@ describe("EventV2", () => {
const id = EventV2.ID.create()
yield* events.replay({
id,
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -1023,6 +1063,7 @@ describe("EventV2", () => {
const exit = yield* events
.replay({
id,
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -1045,6 +1086,7 @@ describe("EventV2", () => {
yield* events.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -1055,6 +1097,7 @@ describe("EventV2", () => {
yield* events.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -1116,6 +1159,7 @@ describe("EventV2", () => {
yield* events.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,

View file

@ -1,5 +1,7 @@
import { expect, test } from "bun:test"
import { Ignore } from "@opencode-ai/core/filesystem/ignore"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
test("match nested and non-nested", () => {
expect(Ignore.match("node_modules/index.js")).toBe(true)
@ -8,3 +10,30 @@ test("match nested and non-nested", () => {
expect(Ignore.match("node_modules/bar")).toBe(true)
expect(Ignore.match("node_modules/bar/")).toBe(true)
})
test("parcel patterns ignore built-in folders at any depth", async () => {
let ignoreGlobs: string[] = []
const watcher = createWrapper({
subscribe: async (
_directory: string,
_callback: (...args: unknown[]) => unknown,
options: { ignoreGlobs?: string[] },
) => {
ignoreGlobs = options.ignoreGlobs ?? []
},
})
await watcher.subscribe("/tmp/project", () => {}, { ignore: Ignore.PATTERNS })
const patterns = ignoreGlobs.map((source) => new RegExp(source))
for (const path of [
"nested/node_modules",
"nested/node_modules/package/index.js",
"nested/.git",
"nested/.git/HEAD",
"nested/dist",
"nested/dist/index.js",
]) {
expect(patterns.some((pattern) => pattern.test(path))).toBe(true)
}
expect(patterns.some((pattern) => pattern.test("nested/src/index.ts"))).toBe(false)
})

View file

@ -2,13 +2,15 @@ import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
@ -34,7 +36,7 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
)
return Effect.provide(
AppNodeBuilder.build(Watcher.node, [
AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
]),
@ -66,7 +68,7 @@ function wait(check: (event: WatcherEvent) => boolean) {
return Effect.gen(function* () {
const events = yield* EventV2.Service
const deferred = yield* Deferred.make<WatcherEvent>()
const fiber = yield* events.subscribe(Watcher.Event.Updated).pipe(
const fiber = yield* events.subscribe(FileSystem.Event.Changed).pipe(
Stream.runForEach((event) => {
if (!check(event.data)) return Effect.void
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
@ -136,7 +138,29 @@ function ready(directory: string) {
})
}
describeWatcher("Watcher", () => {
describeWatcher("LocationWatcher", () => {
it.live("limits file watches to the exact target", () =>
withTmp((directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const watcher = yield* Watcher.Service
const target = path.join(directory, "opencode.json")
const sibling = path.join(directory, "other.json")
const update = yield* watcher
.subscribe({ path: target, type: "file" })
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* fs.writeFileString(sibling, "sibling")
const writes = yield* Effect.suspend(() => fs.writeFileString(target, `target-${Math.random()}`)).pipe(
Effect.repeat(Schedule.spaced("10 millis")),
Effect.forkScoped,
)
const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(writes)))
expect(event.valueOrUndefined?.path).toBe(target)
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
),
)
it.live("publishes root create, update, and delete events", () =>
withTmp(
(directory) =>
@ -175,6 +199,24 @@ describeWatcher("Watcher", () => {
),
)
it.live("ignores dependency, VCS, and build directories at any depth", () =>
withTmp((directory) =>
Effect.gen(function* () {
const afs = yield* FSUtil.Service
yield* ready(directory)
const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
const files = roots.map((root) => path.join(root, "package", "index.js"))
yield* noUpdate(
(event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
concurrency: "unbounded",
discard: true,
}),
)
}),
),
)
it.live("cleanup stops publishing events", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service

View file

@ -0,0 +1,40 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
const server = new Server({ name: "output-schema", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
Promise.resolve(
params?.cursor === "page-2"
? {
tools: [
{
name: "second",
inputSchema: { type: "object" },
outputSchema: {
type: "object",
properties: { value: { type: "number" } },
required: ["value"],
},
},
],
}
: {
tools: [
{
name: "first",
inputSchema: { type: "object" },
outputSchema: {
type: "object",
properties: { value: { type: "string" } },
required: ["value"],
},
},
],
nextCursor: "page-2",
},
),
)
await server.connect(new StdioServerTransport())

View file

@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Effect, Exit } from "effect"
import { Deferred, Effect, Exit, Fiber } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
@ -19,6 +19,27 @@ const input = {
} satisfies Form.CreateInput
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 created = yield* Deferred.make<Form.Info>()
const unsubscribe = yield* events.listen((event) =>
event.type === Form.Event.Created.type
? Deferred.succeed(created, (event.data as { readonly form: Form.Info }).form).pipe(Effect.asVoid)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
const form = yield* Deferred.await(created)
yield* service.cancel(form.id)
expect(yield* Fiber.join(fiber)).toEqual({ status: "cancelled" })
expect(yield* service.state(form.id)).toEqual({ status: "cancelled" })
}),
)
it.effect("supports the temporary global mcp elicitation owner", () =>
Effect.gen(function* () {
const service = yield* Form.Service
@ -92,6 +113,79 @@ describe("Form", () => {
}),
)
it.effect("requires every when condition to match and treats empty when as active", () =>
Effect.gen(function* () {
const service = yield* Form.Service
const created = yield* service.create({
sessionID: "global",
mode: "form",
fields: [
{ key: "a", type: "boolean" },
{ key: "b", type: "boolean" },
{
key: "x",
type: "string",
required: true,
when: [
{ key: "a", op: "eq", value: true },
{ key: "b", op: "eq", value: true },
],
},
{ key: "z", type: "string", required: true, when: [] },
],
})
const missingX = yield* service.reply({ id: created.id, answer: { a: true, b: true, z: "ok" } }).pipe(Effect.flip)
expect(missingX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: x" }))
const inactiveX = yield* service
.reply({ id: created.id, answer: { a: true, b: false, x: "nope", z: "ok" } })
.pipe(Effect.flip)
expect(inactiveX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: x" }))
const missingZ = yield* service.reply({ id: created.id, answer: { a: true, b: false } }).pipe(Effect.flip)
expect(missingZ).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: z" }))
yield* service.reply({ id: created.id, answer: { a: true, b: false, z: "ok" } })
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { a: true, b: false, z: "ok" } })
}),
)
it.effect("evaluates neq against multiselect answers as non-inclusion", () =>
Effect.gen(function* () {
const service = yield* Form.Service
const options = [
{ value: "go", label: "Go" },
{ value: "ts", label: "TypeScript" },
]
const created = yield* service.create({
sessionID: "global",
mode: "form",
fields: [
{ key: "langs", type: "multiselect", options },
{ key: "note", type: "string", required: true, when: [{ key: "langs", op: "neq", value: "go" }] },
],
})
const missing = yield* service.reply({ id: created.id, answer: { langs: ["ts"] } }).pipe(Effect.flip)
expect(missing).toEqual(
new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: note" }),
)
// an answered-but-empty multiselect also satisfies neq
const missingEmpty = yield* service.reply({ id: created.id, answer: { langs: [] } }).pipe(Effect.flip)
expect(missingEmpty).toEqual(
new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: note" }),
)
const inactive = yield* service.reply({ id: created.id, answer: { langs: ["go"], note: "x" } }).pipe(Effect.flip)
expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: note" }))
yield* service.reply({ id: created.id, answer: { langs: ["go"] } })
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { langs: ["go"] } })
}),
)
it.effect("treats unanswered when references as false and cascades inactivity", () =>
Effect.gen(function* () {
const service = yield* Form.Service

View file

@ -2,7 +2,9 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import type { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Effect } from "effect"
import { Tools } from "@opencode-ai/core/tool/tools"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, type Scope } from "effect"
export const toolIdentity = {
agent: AgentV2.ID.make("build"),
@ -34,6 +36,29 @@ export function waitForTool(
})
}
/**
* Registers a core tool plugin's tools against the real registry without booting the
* full plugin host. Only the tool domain is live; focused tool tests exercise
* registration, materialization, and settlement through the same path production uses.
*/
export const registerToolPlugin = <R>(plugin: {
readonly id: string
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
}): Effect.Effect<void, never, R | Tools.Service | Scope.Scope> =>
Effect.gen(function* () {
const tools = yield* Tools.Service
const context: Pick<PluginContext, "tool"> = {
tool: {
register: tools.register,
execute: {
before: () => Effect.die("registerToolPlugin does not support tool hooks"),
after: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
},
}
yield* plugin.effect(context as PluginContext)
})
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
registry.materialize({ model }).pipe(Effect.flatMap((materialized) => materialized.settle(input)))

View file

@ -1,7 +1,9 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { DateTime, Effect, Equal, Hash, Schema } from "effect"
import { Config } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Context, DateTime, Effect, Equal, Hash, Schema, Stream } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
@ -10,6 +12,7 @@ import { LayerNode } from "@opencode-ai/core/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 { 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"
@ -27,6 +30,124 @@ import { ToolRegistry } from "../src/tool/registry"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])))
describe("LocationServiceMap", () => {
it.live("applies ordered plugin config operations during boot", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(path.join(dir.path, "opencode.json"), JSON.stringify({ plugins: ["-*", "opencode.agent"] })),
)
const plugins = yield* Effect.gen(function* () {
const plugins = yield* PluginV2.Service
yield* (yield* PluginSupervisor.Service).ready
return yield* plugins.list()
}).pipe(
Effect.scoped,
Effect.provide(
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
),
)
expect(plugins.map((plugin) => plugin.id)).toEqual([Plugin.ID.make("opencode.agent")])
}),
),
),
)
it.live("reloads the plugin generation after config updates", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
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 supervisor = yield* PluginSupervisor.Service
yield* supervisor.ready
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] })))
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.command")) break
yield* Effect.sleep("20 millis")
}
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.command"])
yield* Effect.promise(() =>
fs.writeFile(
file,
JSON.stringify({
plugins: ["-*", path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts")],
}),
),
)
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* registry.list()).length === 0) break
yield* Effect.sleep("20 millis")
}
expect(yield* registry.list()).toEqual([])
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.agent")) break
yield* Effect.sleep("20 millis")
}
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
}).pipe(
Effect.scoped,
Effect.provide(
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
),
)
}),
),
),
)
it.live("routes located events only to their location", () =>
Effect.acquireRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
).pipe(
Effect.flatMap(([first, second]) =>
Effect.scoped(
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const events = yield* EventV2.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(
Stream.runForEach(() => Effect.sync(() => received.first++)),
Effect.provideContext(firstContext),
Effect.forkScoped({ startImmediately: true }),
)
yield* events.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* Effect.sleep("10 millis")
expect(received).toEqual({ first: 1, second: 0 })
}),
),
),
),
)
it.live("reuses cached services for constructed and decoded location refs", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@ -51,31 +172,38 @@ describe("LocationServiceMap", () => {
),
)
it.live("isolates location state while sharing location policy with catalog", () =>
it.live("isolates catalog state by location", () =>
Effect.acquireRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
).pipe(
Effect.flatMap(([blocked, allowed]) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(
path.join(blocked.path, "opencode.json"),
JSON.stringify({
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "test" }] },
}),
),
)
const update = (directory: string) =>
const update = (directory: string, providerID: ProviderV2.ID) =>
Effect.gen(function* () {
yield* Reference.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "glob")
yield* waitForTool(registry, "shell")
yield* waitForTool(registry, "subagent")
// Tool plugins register during the forked PluginSupervisor boot; wait for
// every expected tool rather than relying on batch ordering.
yield* Effect.forEach(
[
"edit",
"glob",
"grep",
"question",
"read",
"shell",
"skill",
"subagent",
"todowrite",
"webfetch",
"websearch",
"write",
],
(name) => waitForTool(registry, name),
)
return {
providers: yield* catalog.provider.all(),
tools: yield* toolDefinitions(registry),
@ -87,8 +215,11 @@ describe("LocationServiceMap", () => {
),
)
const blockedState = yield* update(blocked.path)
expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false)
const blockedID = ProviderV2.ID.make("blocked-location")
const allowedID = ProviderV2.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)
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
"edit",
"glob",
@ -103,8 +234,9 @@ describe("LocationServiceMap", () => {
"websearch",
"write",
])
const allowedState = yield* update(allowed.path)
expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true)
const allowedState = yield* update(allowed.path, allowedID)
expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true)
expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false)
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
"edit",
"glob",
@ -246,7 +378,7 @@ describe("LocationServiceMap", () => {
})
.pipe(Effect.asVoid),
})
yield* plugins.add(PluginV2.ID.make(reviewer.id), reviewer.effect)
yield* plugins.activate([{ plugin: reviewer }])
expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({
description: "Reviews code",

View file

@ -12,6 +12,7 @@ const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID }
const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
directories: () => Effect.succeed([]),
resolve: () =>
Effect.succeed({

View file

@ -0,0 +1,255 @@
import path from "node:path"
import { describe, expect, test } from "bun:test"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import { ConfigMCP } from "@opencode-ai/core/config/mcp"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { MCP } from "@opencode-ai/core/mcp/index"
import { MCPClient } from "@opencode-ai/core/mcp/client"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } 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 { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
let calls = 0
const mcp = Layer.mock(MCP.Service, {
tools: () =>
Effect.succeed([
new MCP.Tool({
server: MCP.ServerName.make("demo"),
name: "search",
description: "Search",
inputSchema: { type: "object", properties: {} },
outputSchema: {
type: "object",
properties: { ok: { type: "boolean" } },
required: ["ok"],
},
}),
]),
callTool: (input) =>
Effect.sync(() => {
calls += 1
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
tool: input.name,
isError: false,
structured: { ok: true },
content: [],
})
}),
})
const permissions = Layer.mock(PermissionV2.Service, {
assert: (input) =>
Effect.gen(function* () {
if (!assertion) return yield* Effect.die("Permission test is not initialized")
yield* Deferred.succeed(assertion, input)
yield* decision
}),
})
const events = Layer.mock(EventV2.Service, { subscribe: () => Stream.never })
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, McpTool.node]), [
[MCP.node, mcp],
[PermissionV2.node, permissions],
[EventV2.node, events],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
)
describe("MCP errors", () => {
test("expose useful messages", () => {
expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
expect(new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message).toBe(
"failed",
)
expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
})
})
test("MCP tool names match V1 sanitization", () => {
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
})
test("preserves output schema validation across paginated tool discovery", async () => {
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
Promise.resolve(
params?.cursor === "page-2"
? {
tools: [
{
name: "second",
inputSchema: { type: "object" },
outputSchema: {
type: "object",
properties: { value: { type: "number" } },
required: ["value"],
},
},
],
}
: {
tools: [
{
name: "first",
inputSchema: { type: "object" },
outputSchema: {
type: "object",
properties: { value: { type: "string" } },
required: ["value"],
},
},
],
nextCursor: "page-2",
},
),
)
server.setRequestHandler(CallToolRequestSchema, ({ params }) =>
Promise.resolve({
content: [],
structuredContent: { value: params.name === "first" ? 42 : 1 },
}),
)
const client = new Client({ name: "pagination-test", version: "1.0.0" })
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)])
try {
const first = await client.listTools()
const second = await client.listTools({ cursor: first.nextCursor })
expect([...first.tools, ...second.tools].map((tool) => tool.name)).toEqual(["first", "second"])
await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(
"Structured content does not match the tool's output schema",
)
} finally {
await Promise.all([client.close(), server.close()])
}
})
test("retains output schemas across paginated MCP discovery", async () => {
const tools = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
"pagination",
new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
}),
import.meta.dir,
)
return yield* connection.tools()
}),
),
)
expect(tools.map((tool) => ({ name: tool.name, outputSchema: tool.outputSchema }))).toEqual([
{
name: "first",
outputSchema: {
type: "object",
properties: { value: { type: "string" } },
required: ["value"],
},
},
{
name: "second",
outputSchema: {
type: "object",
properties: { value: { type: "number" } },
required: ["value"],
},
},
])
})
it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const execute = (yield* toolDefinitions(registry)).find((tool) => tool.name === "execute")
expect(execute?.description).toContain("tools.demo.search(input: {}): Promise<{ ok: boolean }>")
}),
)
it.effect("waits for permission before calling an MCP tool", () =>
Effect.gen(function* () {
calls = 0
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
const permission = yield* Deferred.make<void>()
decision = Deferred.await(permission)
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const fiber = yield* settleTool(registry, {
sessionID: SessionV2.ID.make("ses_mcp_permission"),
...toolIdentity,
call: {
type: "tool-call",
id: "call_mcp_permission",
name: "execute",
input: { code: "return await tools.demo.search({})" },
},
}).pipe(Effect.forkScoped)
expect(yield* Deferred.await(assertion)).toEqual({
action: "demo_search",
resources: ["*"],
save: ["*"],
metadata: {},
sessionID: SessionV2.ID.make("ses_mcp_permission"),
agent: toolIdentity.agent,
source: {
type: "tool",
messageID: toolIdentity.assistantMessageID,
callID: "call_mcp_permission",
},
})
expect(calls).toBe(0)
yield* Deferred.succeed(permission, undefined)
yield* Fiber.join(fiber)
expect(calls).toBe(1)
}),
)
it.effect("does not call MCP when permission is rejected", () =>
Effect.gen(function* () {
calls = 0
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
decision = Effect.fail(new PermissionV2.RejectedError())
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const settlement = yield* settleTool(registry, {
sessionID: SessionV2.ID.make("ses_mcp_rejected"),
...toolIdentity,
call: {
type: "tool-call",
id: "call_mcp_rejected",
name: "execute",
input: { code: "return await tools.demo.search({})" },
},
})
expect(settlement.result).toEqual({ type: "text", value: "Unable to execute demo_search" })
expect(settlement.output?.structured).toEqual({
toolCalls: [{ tool: "demo.search", status: "error" }],
error: true,
})
expect(calls).toBe(0)
}),
)

View file

@ -149,6 +149,22 @@ describe("ModelsDev Service", () => {
}),
)
it.effect("allows models.dev entries without temperature metadata", () =>
Effect.sync(() => {
const result = Schema.decodeUnknownSync(ModelsDev.Model)({
id: "no-temperature-model",
name: "No Temperature Model",
release_date: "2026-01-01",
attachment: false,
reasoning: false,
tool_call: true,
limit: { context: 128000, output: 8192 },
})
expect(result.temperature).toBeUndefined()
}),
)
it.live("get() returns providers from disk when cache file exists", () =>
Effect.gen(function* () {
yield* writeCache(fixture)

View file

@ -0,0 +1,47 @@
import { expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { Newtype } from "../src/schema"
class UserID extends Newtype<UserID>()("Test.UserID", Schema.NonEmptyString) {}
class ProjectID extends Newtype<ProjectID>()("Test.ProjectID", Schema.NonEmptyString) {}
class Port extends Newtype<Port>()("Test.Port", Schema.FiniteFromString) {}
const User = Schema.Struct({ id: UserID })
test("constructs nominal values from the underlying type", () => {
const id = UserID.make("user-1")
const acceptUserID = (_id: UserID) => undefined
expect(String(id)).toBe("user-1")
acceptUserID(id)
if (false) {
// @ts-expect-error distinct newtypes are not interchangeable
acceptUserID(ProjectID.make("project-1"))
}
})
test("preserves constructor validation", () => {
expect(() => UserID.make("")).toThrow()
})
test("decodes and encodes as a schema", async () => {
const decoded = await Effect.runPromise(Schema.decodeUnknownEffect(User)({ id: "user-1" }))
const encoded = await Effect.runPromise(Schema.encodeEffect(User)(decoded))
expect(String(decoded.id)).toBe("user-1")
expect(encoded).toEqual({ id: "user-1" })
})
test("preserves the underlying schema validation", async () => {
const result = await Effect.runPromise(Schema.decodeUnknownEffect(UserID)("").pipe(Effect.result))
expect(result._tag).toBe("Failure")
})
test("preserves transformed encoded and decoded representations", async () => {
const decoded = await Effect.runPromise(Schema.decodeUnknownEffect(Port)("8080"))
const encoded = await Effect.runPromise(Schema.encodeEffect(Port)(decoded))
expect(Number(decoded)).toBe(8080)
expect(encoded).toBe("8080")
})

View file

@ -1,8 +1,12 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Schema } from "effect"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { define } from "@opencode-ai/plugin/v2/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 { PluginHost } from "@opencode-ai/core/plugin/host"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool/tool"
@ -13,40 +17,36 @@ import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer)
class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSecret") {}
describe("PluginV2", () => {
it.effect("waits for a plugin and returns immediately once active", () =>
it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const id = PluginV2.ID.make("waited")
const waiting = yield* plugins.wait(id).pipe(Effect.forkChild)
const events = yield* EventV2.Service
const host = yield* PluginHost.make(plugins)
const received = yield* host.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("10 millis")
yield* plugins.add(id, () => Effect.void)
yield* Fiber.join(waiting)
yield* plugins.wait(id)
yield* events.publish(ConfigSchema.Event.Updated, {})
expect((yield* Fiber.join(received)).valueOrUndefined?.type).toBe("config.updated")
}),
)
it.effect("propagates plugin activation defects to waiters", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const id = PluginV2.ID.make("failed")
const waiting = yield* plugins.wait(id).pipe(Effect.exit, Effect.forkChild)
const added = yield* plugins.add(id, () => Effect.die("boom")).pipe(Effect.exit)
const pending = yield* Fiber.join(waiting)
const later = yield* plugins.wait(id).pipe(Effect.exit)
expect(Exit.isFailure(added)).toBe(true)
expect(Exit.isFailure(pending)).toBe(true)
expect(Exit.isFailure(later)).toBe(true)
}),
)
it.effect("adds, replaces, and removes plugins", () =>
it.effect("skips identical generations and replaces changed plugin versions", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const events = yield* EventV2.Service
let description = "first"
const updated = yield* events
.subscribe(Plugin.Event.Updated)
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
const managed = () =>
define({
@ -61,19 +61,102 @@ describe("PluginV2", () => {
.pipe(Effect.asVoid),
})
yield* plugins.add(PluginV2.ID.make("managed"), managed().effect)
yield* plugins.activate([{ plugin: managed() }])
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
description = "second"
yield* plugins.add(PluginV2.ID.make("managed"), managed().effect)
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
yield* plugins.activate([{ plugin: managed() }])
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
yield* plugins.remove(PluginV2.ID.make("managed"))
yield* plugins.activate([{ plugin: managed(), version: "next" }])
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
expect(yield* Fiber.join(updated)).toHaveLength(2)
yield* plugins.activate([])
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
}),
)
it.effect("rejects duplicate IDs before replacing the active generation", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const active = Plugin.ID.make("active")
const duplicate = "duplicate"
yield* plugins.activate([{ plugin: { id: active, effect: () => Effect.void } }])
const result = yield* plugins
.activate([
{ plugin: { id: duplicate, effect: () => Effect.void } },
{ plugin: { id: duplicate, effect: () => Effect.void } },
])
.pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
expect(yield* plugins.list()).toEqual([{ id: active }])
}),
)
it.effect("retries the same generation after materialization fails", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
let fail = true
const plugin = define({
id: "retry",
effect: (ctx) =>
ctx.agent
.transform(() => {
if (fail) throw new Error("materialization failed")
})
.pipe(Effect.asVoid),
})
expect(Exit.isFailure(yield* plugins.activate([{ plugin }]).pipe(Effect.exit))).toBe(true)
fail = false
yield* plugins.activate([{ plugin }])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("retry") }])
}),
)
it.effect("closes the previous generation in reverse order", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const closed: string[] = []
yield* plugins.activate(
["first", "second"].map((id) => ({
plugin: {
id,
effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))),
},
})),
)
yield* plugins.activate([])
expect(closed).toEqual(["second", "first"])
}),
)
it.effect("isolates plugins from ambient services", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
let visible = true
const plugin = define({
id: "isolated",
effect: () =>
Effect.serviceOption(Secret).pipe(
Effect.tap((secret) => Effect.sync(() => (visible = secret._tag === "Some"))),
Effect.asVoid,
),
})
yield* plugins.activate([{ plugin }]).pipe(Effect.provideService(Secret, "secret"))
expect(visible).toBe(false)
}),
)
it.effect("registers location tools through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
@ -93,18 +176,51 @@ describe("PluginV2", () => {
.pipe(Effect.orDie),
})
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
yield* plugins.activate([{ plugin }])
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
"plugin_tool",
)
yield* plugins.remove(PluginV2.ID.make(plugin.id))
yield* plugins.activate([])
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).not.toContain(
"plugin_tool",
)
}),
)
it.effect("groups tool names and defers registrations from direct exposure", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
const tool = (description: string) =>
Tool.make({
description,
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
})
const plugin = define({
id: "grouped-tools",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool.register({ plain: tool("Plain") }).pipe(Effect.orDie)
yield* ctx.tool.register({ "look/up": tool("Lookup") }, { group: "context 7" }).pipe(Effect.orDie)
yield* ctx.tool
.register({ search: tool("Search") }, { group: "context 7", deferred: true })
.pipe(Effect.orDie)
}),
})
yield* plugins.activate([{ plugin }])
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([
"plain",
"context_7_look_up",
"execute",
])
}),
)
it.effect("fires before/after tool hooks with mutable events around settlement", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
@ -147,7 +263,7 @@ describe("PluginV2", () => {
}),
})
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
yield* plugins.activate([{ plugin }])
const materialized = yield* registry.materialize({ model: testModel })
const settlement = yield* materialized.settle({

View file

@ -23,7 +23,11 @@ describe("CommandPlugin.Plugin", () => {
const command = yield* CommandV2.Service
yield* CommandPlugin.Plugin.effect(
host({
command: { transform: command.transform, reload: command.reload },
command: {
list: () => Effect.die("unused command.list"),
transform: command.transform,
reload: command.reload,
},
}),
).pipe(
Effect.provideService(

View file

@ -0,0 +1,7 @@
import { define } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect"
export default define({
id: "failing-plugin",
effect: () => Effect.die("plugin failed"),
})

View file

@ -0,0 +1,29 @@
import { define } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect"
export default define({
id: "variant-source",
effect: (ctx) =>
ctx.catalog
.transform((catalog) => {
catalog.provider.update("configured", (provider) => {
provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
})
catalog.model.update("configured", "glm-5.2", (model) => {
model.api = {
id: "glm-5.2",
type: "aisdk",
package: "@ai-sdk/openai-compatible",
}
model.variants = [
{
id: "high",
settings: {},
headers: { custom: "true" },
body: {},
},
]
})
})
.pipe(Effect.asVoid),
})

View file

@ -6,7 +6,7 @@ import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
type Overrides = Partial<Omit<PluginContext, "options">>
@ -23,14 +23,33 @@ export function host(overrides: Overrides = {}): PluginContext {
language: () => Effect.die("unused aisdk.language"),
},
catalog: overrides.catalog ?? {
provider: {
list: () => Effect.die("unused catalog.provider.list"),
get: () => Effect.die("unused catalog.provider.get"),
},
model: {
list: () => Effect.die("unused catalog.model.list"),
default: () => Effect.die("unused catalog.model.default"),
},
transform: () => Effect.die("unused catalog.transform"),
reload: () => Effect.die("unused catalog.reload"),
},
command: overrides.command ?? {
list: () => Effect.die("unused command.list"),
transform: () => Effect.die("unused command.transform"),
reload: () => Effect.die("unused command.reload"),
},
event: overrides.event ?? {
subscribe: () => Stream.empty,
},
integration: overrides.integration ?? {
list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"),
connectKey: () => Effect.die("unused integration.connectKey"),
connectOauth: () => Effect.die("unused integration.connectOauth"),
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
attemptComplete: () => Effect.die("unused integration.attemptComplete"),
attemptCancel: () => Effect.die("unused integration.attemptCancel"),
transform: () => Effect.die("unused integration.transform"),
reload: () => Effect.die("unused integration.reload"),
connection: {
@ -39,14 +58,15 @@ export function host(overrides: Overrides = {}): PluginContext {
},
},
plugin: overrides.plugin ?? {
add: () => Effect.die("unused plugin.add"),
remove: () => Effect.die("unused plugin.remove"),
list: () => Effect.die("unused plugin.list"),
},
reference: overrides.reference ?? {
list: () => Effect.die("unused reference.list"),
transform: () => Effect.die("unused reference.transform"),
reload: () => Effect.die("unused reference.reload"),
},
skill: overrides.skill ?? {
list: () => Effect.die("unused skill.list"),
transform: () => Effect.die("unused skill.transform"),
reload: () => Effect.die("unused skill.reload"),
},
@ -97,6 +117,14 @@ export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] {
return {
provider: {
list: () => Effect.die("unused catalog.provider.list"),
get: () => Effect.die("unused catalog.provider.get"),
},
model: {
list: () => Effect.die("unused catalog.model.list"),
default: () => Effect.die("unused catalog.model.default"),
},
reload: catalog.reload,
transform: (callback) =>
catalog.transform((draft) =>
@ -161,6 +189,13 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"
export function integrationHost(integration: Integration.Interface): PluginContext["integration"] {
return {
list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"),
connectKey: () => Effect.die("unused integration.connectKey"),
connectOauth: () => Effect.die("unused integration.connectOauth"),
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
attemptComplete: () => Effect.die("unused integration.attemptComplete"),
attemptCancel: () => Effect.die("unused integration.attemptCancel"),
reload: integration.reload,
connection: {
active: (id) => integration.connection.active(Integration.ID.make(id)),

View file

@ -11,6 +11,35 @@ import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
describe("fromPromise", () => {
it.effect("forwards standard client reads", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
const seen: string[] = []
const promisePlugin = define({
id: "promise-client-reads",
setup: async (ctx) => {
const results = await Promise.all([
ctx.agent.list(),
ctx.catalog.provider.list(),
ctx.catalog.model.list(),
ctx.command.list(),
ctx.integration.list(),
ctx.plugin.list(),
ctx.reference.list(),
ctx.skill.list(),
])
seen.push(...results.map((result) => result.location.directory))
},
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
expect(seen).toHaveLength(8)
expect(new Set(seen).size).toBe(1)
}),
)
it.effect("loads a promise plugin and registers a transform hook", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service

View file

@ -19,7 +19,7 @@ const addPlugin = Effect.fn(function* () {
describe("KiloPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("kilo"))),
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.kilo")),
)
it.effect("applies legacy referer headers only to kilo", () =>

View file

@ -21,7 +21,7 @@ const addPlugin = Effect.fn(function* () {
describe("LLMGatewayPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmgateway"))),
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.llmgateway")),
)
it.effect("applies legacy referer headers only to enabled llmgateway", () =>

View file

@ -19,7 +19,7 @@ const addPlugin = Effect.fn(function* () {
describe("NvidiaPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("nvidia"))),
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.nvidia")),
)
it.effect("applies NVIDIA tracking headers only to nvidia", () =>

View file

@ -22,7 +22,7 @@ const addPlugin = Effect.fn(function* () {
describe("OpenRouterPlugin", () => {
it.effect("is registered so legacy OpenRouter behavior can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("openrouter"))),
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.openrouter")),
)
it.effect("applies legacy referer headers only to openrouter", () =>

View file

@ -43,9 +43,11 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
describe("SnowflakeCortexPlugin", () => {
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
Effect.sync(() => {
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex"))
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake-cortex")
const ids = ProviderPlugins.map((p) => p.id)
expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible"))
expect(ids.indexOf("opencode.provider.snowflake-cortex")).toBeLessThan(
ids.indexOf("opencode.provider.openai-compatible"),
)
}),
)

View file

@ -24,7 +24,7 @@ function required<T>(value: T | undefined): T {
describe("ZenmuxPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("zenmux"))),
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.zenmux")),
)
it.effect("applies the exact legacy Zenmux headers", () =>

View file

@ -19,7 +19,15 @@ describe("SkillPlugin.Plugin", () => {
it.effect("registers built-in skills", () =>
Effect.gen(function* () {
const skill = yield* SkillV2.Service
yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })).pipe(
yield* SkillPlugin.Plugin.effect(
host({
skill: {
list: () => Effect.die("unused skill.list"),
transform: skill.transform,
reload: skill.reload,
},
}),
).pipe(
Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })),
Effect.provideService(
Location.Service,

View file

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

View file

@ -2,16 +2,72 @@ import { afterAll, describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { Effect, Schema } from "effect"
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 { Global } from "@opencode-ai/core/global"
import { ProjectV2 } 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/core/util/hash"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(ProjectV2.node))
const it = testEffect(
Layer.merge(AppNodeBuilder.build(ProjectV2.node), AppNodeBuilder.build(Database.node)),
)
describe("ProjectV2.list", () => {
it.effect("returns complete projects ordered by recent update", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const project = yield* ProjectV2.Service
yield* db
.insert(ProjectTable)
.values([
{
id: ProjectV2.ID.make("older"),
worktree: abs("/older"),
vcs: "git",
name: "Older",
icon_color: "#000000",
commands: { start: "bun dev" },
sandboxes: [abs("/older/sandbox")],
time_created: 1,
time_updated: 1,
},
{
id: ProjectV2.ID.make("newer"),
worktree: abs("/newer"),
sandboxes: [],
time_created: 2,
time_updated: 2,
time_initialized: 3,
},
])
.run()
expect(yield* project.list()).toEqual([
{
id: ProjectV2.ID.make("newer"),
worktree: abs("/newer"),
time: { created: 2, updated: 2, initialized: 3 },
sandboxes: [],
},
{
id: ProjectV2.ID.make("older"),
worktree: abs("/older"),
vcs: "git",
name: "Older",
icon: { color: "#000000" },
commands: { start: "bun dev" },
time: { created: 1, updated: 1 },
sandboxes: [abs("/older/sandbox")],
},
])
}),
)
})
const globalConfig = await tmpdir()
afterAll(() => globalConfig[Symbol.asyncDispose]())

View file

@ -33,6 +33,7 @@ const model = Model.make({
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
@ -85,17 +86,13 @@ describe("SessionV2.compact", () => {
const prompt = Prompt.make({ text: "Please compact this session history." })
yield* events.publish(SessionEvent.PromptAdmitted, {
sessionID: created.id,
messageID,
timestamp: DateTime.makeUnsafe(0),
inputID: messageID,
prompt,
delivery: "steer",
})
yield* events.publish(SessionEvent.Prompted, {
yield* events.publish(SessionEvent.PromptPromoted, {
sessionID: created.id,
messageID,
timestamp: DateTime.makeUnsafe(0),
prompt,
delivery: "steer",
inputID: messageID,
})
yield* session.compact({ sessionID: created.id })

View file

@ -17,11 +17,11 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInput } from "@opencode-ai/core/session/input"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
@ -31,6 +31,7 @@ import { tmpdir } from "./fixture/tmpdir"
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
@ -62,6 +63,13 @@ const assertCreateInputTypes = (session: SessionV2.Interface) => {
}
void assertCreateInputTypes
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("SessionV2.create", () => {
it.effect("creates a fresh projected session when the ID is omitted", () =>
Effect.gen(function* () {
@ -190,8 +198,6 @@ describe("SessionV2.create", () => {
yield* SessionInput.promoteSteers(db, events, parent.id)
yield* events.publish(SessionEvent.Synthetic, {
sessionID: parent.id,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
text: "parent note",
})
@ -208,7 +214,7 @@ describe("SessionV2.create", () => {
expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
expect(history).toHaveLength(1)
expect(history[0]).toMatchObject({
type: "session.next.forked",
type: "session.forked",
durable: { seq: 0 },
data: { sessionID: forked.id, parentID: parent.id },
})
@ -260,7 +266,7 @@ describe("SessionV2.create", () => {
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
expect(context).toMatchObject([{ text: "First" }])
expect(context[0]?.id).not.toBe(first.id)
expect(history[0]).toMatchObject({ data: { messageID: second.id } })
expect(history[0]).toMatchObject({ data: { from: second.id } })
}),
)
@ -373,8 +379,8 @@ describe("SessionV2.create", () => {
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
).toMatchObject([
{ durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } },
{ durable: { seq: 2 }, type: "session.next.prompted" },
{ durable: { seq: 1 }, type: "session.prompt.admitted", data: { prompt: { text: "Hello" } } },
{ durable: { seq: 2 }, type: "session.prompt.promoted" },
])
}),
)
@ -399,6 +405,7 @@ describe("SessionV2.create", () => {
.all()
.pipe(Effect.orDie)).map((event) => ({
id: event.id,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
@ -459,7 +466,7 @@ describe("SessionV2.create", () => {
).toEqual([
[0, EventV2.versionedType(SessionV1.Event.Created.type, 1)],
[1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)],
[2, EventV2.versionedType(SessionEvent.Prompted.type, 1)],
[2, EventV2.versionedType(SessionEvent.PromptPromoted.type, 1)],
])
}).pipe(Effect.provide(Layer.fresh(targetLayer)))
}),
@ -476,20 +483,43 @@ describe("SessionV2.create", () => {
}),
)
it.effect("reports unfinished Session operations as unavailable", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const created = yield* session.create({ location })
const unavailable = (
effect: Effect.Effect<void, SessionV2.NotFoundError | SessionV2.OperationUnavailableError>,
) =>
effect.pipe(
Effect.flip,
Effect.map((error) => (error instanceof SessionV2.OperationUnavailableError ? error.operation : "not-found")),
)
it.live("runs a shell command and projects the started/ended shell message", () =>
withTmp((directory) =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const created = yield* session.create({
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
})
expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
}),
yield* session.shell({ sessionID: created.id, command: "echo hello" })
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
expect(shell).toMatchObject({ type: "shell", shell: { command: "echo hello", status: "exited", exit: 0 } })
expect(shell?.output?.output).toContain("hello")
expect(shell?.output?.truncated).toBe(false)
expect(shell?.time.completed).toBeDefined()
}),
),
)
it.live("still emits shell ended for a failing command", () =>
withTmp((directory) =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const created = yield* session.create({
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
})
yield* session.shell({ sessionID: created.id, command: "false" })
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
expect(shell).toMatchObject({ type: "shell", shell: { command: "false", status: "exited" } })
expect(shell?.shell.exit).not.toBe(0)
expect(shell?.time.completed).toBeDefined()
}),
),
)
it.effect("switches the selected agent through the durable Session event", () =>
@ -502,7 +532,7 @@ describe("SessionV2.create", () => {
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }])
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
}),
)
@ -533,9 +563,9 @@ describe("SessionV2.create", () => {
yield* session.switchModel({ sessionID: created.id, model })
expect(yield* session.get(created.id)).toMatchObject({ model })
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "session.next.model.switched", data: { 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 })
}),
)

View file

@ -33,12 +33,29 @@ 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 { tempLocationLayer } from "./fixture/location"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { testEffect } from "./lib/effect"
import { settleTool, testModel } from "./lib/tool"
import { registerToolPlugin, settleTool, testModel } from "./lib/tool"
const readToolNode = makeLocationNode({
name: "test/read-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(ReadTool.Plugin)),
deps: [
ToolRegistry.toolsNode,
ReadToolFileSystem.node,
LocationMutation.node,
Image.node,
PermissionV2.node,
SessionInstructions.node,
FSUtil.node,
Location.node,
],
})
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
@ -69,7 +86,7 @@ const testLayer = AppNodeBuilder.build(
FSUtil.node,
LocationMutation.node,
ReadToolFileSystem.node,
ReadTool.node,
readToolNode,
ToolRegistry.node,
ToolRegistry.toolsNode,
ToolHooks.node,
@ -117,8 +134,6 @@ const seedSynthetic = (sessionID: SessionV2.ID, paths: string[]) =>
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.Synthetic, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
text: `Instructions from: ${paths[0]}\nprior`,
description: `Loaded ${paths[0]}`,
metadata: { instruction: { paths } },
@ -156,7 +171,9 @@ describe("SessionInstructions", () => {
expect(firstInjected[0]!.text).toBe(
`Instructions from: ${deepPath}\ndeep-instructions\n\nInstructions from: ${subPath}\nsub-instructions`,
)
expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, deepPath)}, ${path.relative(dir, subPath)}`)
expect(firstInjected[0]!.description).toBe(
`Loaded ${path.relative(dir, deepPath)}, ${path.relative(dir, subPath)}`,
)
// The synthetic's metadata carries the durable dedup ledger.
expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [deepPath, subPath] } })
expect(firstInjected[0]!.text).not.toContain("root-instructions")
@ -192,47 +209,49 @@ describe("SessionInstructions", () => {
// Seed the durable history with a prior synthetic that already claims sub's AGENTS.md
// via the instruction metadata ledger.
yield* seedSynthetic(sessionID, [subPath])
expect((yield* synthetics(sessionID))).toHaveLength(1)
expect(yield* synthetics(sessionID)).toHaveLength(1)
yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt"))
// The durable claim on the prior synthetic prevents re-injection; no new synthetic.
expect((yield* synthetics(sessionID))).toHaveLength(1)
expect(yield* synthetics(sessionID)).toHaveLength(1)
}),
)
it.effect("discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const dir = location.directory
const rootPath = path.resolve(dir, "AGENTS.md")
const pkgPath = path.resolve(dir, "packages", "foo", "AGENTS.md")
yield* mkdir(path.resolve(dir, "packages", "foo"))
yield* writeAgents(rootPath, "root-instructions")
yield* writeAgents(pkgPath, "pkg-instructions")
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "packages", "foo", "file.txt"), "content"))
it.effect(
"discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read",
() =>
Effect.gen(function* () {
const location = yield* Location.Service
const dir = location.directory
const rootPath = path.resolve(dir, "AGENTS.md")
const pkgPath = path.resolve(dir, "packages", "foo", "AGENTS.md")
yield* mkdir(path.resolve(dir, "packages", "foo"))
yield* writeAgents(rootPath, "root-instructions")
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 sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.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
// the Location root (already supplied by the core/instructions baseline).
yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
// Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
// the Location root (already supplied by the core/instructions baseline).
yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
const firstInjected = yield* synthetics(sessionID)
expect(firstInjected).toHaveLength(1)
expect(firstInjected[0]!.text).toBe(`Instructions from: ${pkgPath}\npkg-instructions`)
expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, pkgPath)}`)
expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [pkgPath] } })
expect(firstInjected[0]!.text).not.toContain("root-instructions")
const firstInjected = yield* synthetics(sessionID)
expect(firstInjected).toHaveLength(1)
expect(firstInjected[0]!.text).toBe(`Instructions from: ${pkgPath}\npkg-instructions`)
expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, pkgPath)}`)
expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [pkgPath] } })
expect(firstInjected[0]!.text).not.toContain("root-instructions")
// A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is
// already injected for this session, so nothing new is emitted.
yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
// A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is
// already injected for this session, so nothing new is emitted.
yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
expect((yield* synthetics(sessionID))).toHaveLength(1)
}),
expect(yield* synthetics(sessionID)).toHaveLength(1)
}),
)
it.effect("listing the Location root directory injects no instructions", () =>
@ -253,7 +272,7 @@ describe("SessionInstructions", () => {
// dropped by the dirname filter, and up() only walks upward so nested dirs are unseen.
yield* settleTool(registry, readCall(sessionID, "call-root-list", "."))
expect((yield* synthetics(sessionID))).toHaveLength(0)
expect(yield* synthetics(sessionID)).toHaveLength(0)
}),
)

View file

@ -18,6 +18,7 @@ import { testEffect } from "./lib/effect"
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
@ -40,14 +41,14 @@ describe("SessionV2.log", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const created = yield* session.create({ location })
yield* session.rename({ sessionID: created.id, title: "renamed" })
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)
// Session creation commits a non-public durable event, so the marker's
// seq covers more of the aggregate than the public events emitted.
expect(items.map((item) => item.type)).toEqual(["session.next.renamed", "log.synced"])
expect(items.map((item) => item.type)).toEqual(["session.renamed", "log.synced"])
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: watermark })
}),
)
@ -64,7 +65,7 @@ describe("SessionV2.log", () => {
yield* session.rename({ sessionID: created.id, title: "renamed live" })
const items = Array.from(yield* Fiber.join(fiber))
expect(items.map((item) => item.type)).toEqual(["log.synced", "session.next.renamed"])
expect(items.map((item) => item.type)).toEqual(["log.synced", "session.renamed"])
}),
)
@ -78,7 +79,7 @@ describe("SessionV2.log", () => {
it.effect("reads across undecodable gaps in aggregate order and marks the true log position", () =>
Effect.gen(function* () {
const GapEvent = EventV2.define({
const GapEvent = EventV2.durable({
type: "test.session.log.gap",
durable: { aggregate: "sessionID", version: 1 },
schema: { sessionID: SessionV2.ID, value: Schema.String },
@ -137,7 +138,7 @@ describe("SessionV2 watermarks", () => {
const events = yield* EventV2.Service
const first = yield* session.create({ location })
const second = yield* session.create({ location })
yield* session.rename({ sessionID: first.id, title: "renamed" })
yield* session.rename({ sessionID: first.id, title: "session.renamed" })
const page = yield* session.list()
const sequences = yield* events.sequences([first.id, second.id])

View file

@ -19,6 +19,7 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInput } from "@opencode-ai/core/session/input"
import { Shell } from "@opencode-ai/schema/shell"
import {
SessionContextCheckpointTable,
SessionInputTable,
@ -33,6 +34,7 @@ const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.no
const sessionID = SessionV2.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 encodeMessage = Schema.encodeSync(SessionMessage.Message)
const assistantRow = (
@ -79,7 +81,6 @@ describe("SessionProjector", () => {
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] },
})
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({
@ -87,17 +88,15 @@ describe("SessionProjector", () => {
snapshot: "tree",
files: [],
})
yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID, timestamp: DateTime.makeUnsafe(2) })
yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID })
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toBeNull()
yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
timestamp: DateTime.makeUnsafe(3),
revert: { messageID: boundary, files: [] },
})
yield* events.publish(SessionEvent.RevertEvent.Committed, {
sessionID,
messageID: boundary,
timestamp: DateTime.makeUnsafe(4),
})
expect(
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
@ -131,37 +130,29 @@ describe("SessionProjector", () => {
yield* events.publish(SessionEvent.PromptAdmitted, {
sessionID,
messageID: SessionMessage.ID.make("msg_first"),
timestamp: created,
inputID: SessionMessage.ID.make("msg_first"),
prompt: Prompt.make({ text: "first" }),
delivery: "steer",
})
yield* events.publish(
SessionEvent.Prompted,
SessionEvent.PromptPromoted,
{
sessionID,
messageID: SessionMessage.ID.make("msg_first"),
timestamp: created,
prompt: Prompt.make({ text: "first" }),
delivery: "steer",
inputID: SessionMessage.ID.make("msg_first"),
},
{ id: EventV2.ID.make("evt_z") },
)
yield* events.publish(SessionEvent.PromptAdmitted, {
sessionID,
messageID: SessionMessage.ID.make("msg_second"),
timestamp: created,
inputID: SessionMessage.ID.make("msg_second"),
prompt: Prompt.make({ text: "second" }),
delivery: "steer",
})
yield* events.publish(
SessionEvent.Prompted,
SessionEvent.PromptPromoted,
{
sessionID,
messageID: SessionMessage.ID.make("msg_second"),
timestamp: created,
prompt: Prompt.make({ text: "second" }),
delivery: "steer",
inputID: SessionMessage.ID.make("msg_second"),
},
{ id: EventV2.ID.make("evt_a") },
)
@ -190,7 +181,7 @@ describe("SessionProjector", () => {
}).pipe(Effect.provide(sessionsLayer)),
)
it.effect("marks an inbox row promoted with the Prompted event sequence", () =>
it.effect("marks an inbox row promoted with the PromptPromoted event sequence", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
@ -220,12 +211,9 @@ describe("SessionProjector", () => {
})
if (!admitted) return yield* Effect.die("Prompt admission failed")
const event = yield* events.publish(SessionEvent.Prompted, {
const event = yield* events.publish(SessionEvent.PromptPromoted, {
sessionID,
timestamp: admitted.timeCreated,
messageID: id,
prompt: Prompt.make({ text: "promote me" }),
delivery: "steer",
inputID: id,
})
expect(
@ -251,54 +239,59 @@ describe("SessionProjector", () => {
directory: "/project",
title: "test",
version: "test",
model: previousModel,
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.AgentSwitched, {
yield* events.publish(SessionEvent.AgentSelected, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
agent: "build",
})
yield* events.publish(SessionEvent.ModelSwitched, {
yield* events.publish(SessionEvent.ModelSelected, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
model,
})
yield* events.publish(SessionEvent.Synthetic, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
text: "synthetic context",
metadata: { source: "projector-test" },
})
yield* events.publish(SessionEvent.Shell.Started, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
callID: "shell-1",
command: "pwd",
shell: Shell.Info.make({
id: Shell.ID.make("sh_projector"),
status: "running",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/tmp/sh_projector.out",
metadata: {},
time: { started: 0 },
}),
})
yield* events.publish(SessionEvent.Shell.Ended, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
callID: "shell-1",
output: "/project",
shell: Shell.Info.make({
id: Shell.ID.make("sh_projector"),
status: "exited",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/tmp/sh_projector.out",
exit: 0,
metadata: {},
time: { started: 0, completed: 1 },
}),
output: { output: "/project", cursor: 8, size: 8, truncated: false },
})
const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
messageID: compactionID,
timestamp: created,
reason: "manual",
})
yield* events.publish(SessionEvent.Compaction.Delta, {
sessionID,
messageID: compactionID,
timestamp: created,
text: "partial",
})
expect(
@ -319,8 +312,6 @@ describe("SessionProjector", () => {
).toEqual([])
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(1),
reason: "manual",
text: "summary",
recent: "recent context",
@ -348,9 +339,11 @@ describe("SessionProjector", () => {
text: "synthetic context",
metadata: { source: "projector-test" },
})
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
expect(messages.find((message) => message.type === "shell")).toMatchObject({
output: "/project",
time: { completed: DateTime.makeUnsafe(1) },
shell: { command: "pwd", status: "exited", exit: 0 },
output: { output: "/project", truncated: false },
time: { completed: DateTime.makeUnsafe(0) },
})
expect(messages.find((message) => message.type === "compaction")).toMatchObject({
summary: "summary",
@ -388,13 +381,20 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const id = SessionMessage.ID.make("msg_creator_collision")
const {
id: _,
type,
...data
} = encodeMessage({ id, sessionID, type: "synthetic", text: "existing", time: { created } })
yield* db
.insert(SessionMessageTable)
.values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data })
.run()
yield* events.publish(SessionEvent.Synthetic, { sessionID, messageID: id, timestamp: created, text: "keep me" })
const exit = yield* events
.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: id,
timestamp: created,
agent: "build",
model,
})
@ -464,7 +464,6 @@ describe("SessionProjector", () => {
const service = yield* EventV2.Service
yield* service.publish(SessionEvent.Step.Ended, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
finish: "stop",
cost: 0,
@ -485,7 +484,7 @@ describe("SessionProjector", () => {
expect(messages[1]).toMatchObject({
type: "assistant",
finish: "stop",
time: { completed: DateTime.makeUnsafe(1) },
time: { completed: DateTime.makeUnsafe(0) },
})
}),
)
@ -526,7 +525,6 @@ describe("SessionProjector", () => {
yield* service.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
timestamp: DateTime.makeUnsafe(3),
textID: "text-stale",
})

View file

@ -201,7 +201,6 @@ describe("SessionV2.prompt", () => {
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
timestamp: yield* DateTime.now,
revert: { messageID: boundary.id, files: [] },
})
expect((yield* session.get(sessionID)).revert?.messageID).toBe(boundary.id)
@ -257,16 +256,16 @@ describe("SessionV2.prompt", () => {
const streamed = Array.from(yield* Fiber.join(fiber))
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
[0, "session.next.prompt.admitted"],
[1, "session.next.prompt.admitted"],
[2, "session.next.prompted"],
[3, "session.next.prompted"],
[0, "session.prompt.admitted"],
[1, "session.prompt.admitted"],
[2, "session.prompt.promoted"],
[3, "session.prompt.promoted"],
])
expect(
Array.from(
yield* publicEvents({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect),
).map((event): [number | undefined, string] => [event.durable?.seq, event.type]),
).toEqual([[1, "session.next.prompt.admitted"]])
).toEqual([[1, "session.prompt.admitted"]])
}),
)
@ -429,7 +428,7 @@ describe("SessionV2.prompt", () => {
{ concurrency: "unbounded" },
)
expect(yield* eventCount(EventV2.versionedType(SessionEvent.Prompted.type, 1))).toBe(1)
expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptPromoted.type, 1))).toBe(1)
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 })
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: messageID, type: "user", text: "Promote once" },
@ -467,6 +466,7 @@ describe("SessionV2.prompt", () => {
yield* events.replayAll(
recorded.map((event) => ({
id: event.id,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
@ -514,13 +514,23 @@ describe("SessionV2.prompt", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.Synthetic, {
const { db } = yield* Database.Service
const {
id: _,
type,
...data
} = encodeMessage({
id: messageID,
sessionID,
messageID,
timestamp: yield* DateTime.now,
type: "synthetic",
text: "Existing history",
time: { created: DateTime.makeUnsafe(0) },
})
yield* db
.insert(SessionMessageTable)
.values({ id: messageID, session_id: sessionID, type, seq: 0, time_created: 0, data })
.run()
.pipe(Effect.orDie)
const failure = yield* session
.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false })

View file

@ -7,6 +7,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentAttachment, FileAttachment } from "@opencode-ai/core/session/prompt"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { SessionV2 } from "@opencode-ai/core/session"
import { Shell } from "@opencode-ai/schema/shell"
import { DateTime } from "effect"
const created = DateTime.makeUnsafe(0)
@ -51,13 +52,13 @@ describe("toLLMMessages", () => {
const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
const messages = toLLMMessages(
[
SessionMessage.AgentSwitched.make({
SessionMessage.AgentSelected.make({
id: id("agent"),
type: "agent-switched",
agent: "build",
time: { created },
}),
SessionMessage.ModelSwitched.make({
SessionMessage.ModelSelected.make({
id: id("model"),
type: "model-switched",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
@ -87,9 +88,18 @@ describe("toLLMMessages", () => {
SessionMessage.Shell.make({
id: id("shell"),
type: "shell",
callID: "shell-1",
command: "pwd",
output: "/project",
shell: Shell.Info.make({
id: Shell.ID.make("sh_test"),
status: "exited",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/tmp/sh_test.out",
exit: 0,
metadata: {},
time: { started: 0, completed: 0 },
}),
output: { output: "/project", cursor: 8, size: 8, truncated: false },
time: { created, completed: created },
}),
SessionMessage.Compaction.make({
@ -354,7 +364,7 @@ Recent work
state: SessionMessage.ToolStateError.make({
status: "error",
input: { query: "Effect" },
error: { type: "unknown", message: "Provider turn interrupted" },
error: { type: "unknown", message: "Step interrupted" },
content: [],
structured: {},
}),
@ -362,7 +372,7 @@ Recent work
}),
],
finish: "error",
error: { type: "unknown", message: "Provider turn interrupted" },
error: { type: "unknown", message: "Step interrupted" },
time: { created, completed: created },
}),
],
@ -386,7 +396,7 @@ Recent work
result: {
type: "error",
value: {
error: { type: "unknown", message: "Provider turn interrupted" },
error: { type: "unknown", message: "Step interrupted" },
content: [],
structured: {},
},

View file

@ -98,7 +98,7 @@ const execution = Layer.effect(
Effect.gen(function* () {
const sessionRunner = yield* SessionRunner.Service
const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
})
return SessionExecution.Service.of({
active: coordinator.active,
@ -193,12 +193,12 @@ describe("SessionRunnerLLM recorded", () => {
.orderBy(EventTable.seq)
.all()).map((event) => event.type),
).toEqual([
"session.next.prompt.admitted.1",
"session.next.prompted.1",
"session.next.step.started.1",
"session.next.text.started.1",
"session.next.text.ended.1",
"session.next.step.ended.2",
"session.prompt.admitted.1",
"session.prompt.promoted.1",
"session.step.started.1",
"session.text.started.1",
"session.text.ended.1",
"session.step.ended.1",
])
}),
)

View file

@ -76,7 +76,7 @@ test("local tool success serializes media base64 once and reconstructs from stru
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(publisher.publish(result))
const success = published.find((event) => event.type === "session.next.tool.success.1")
const success = published.find((event) => event.type === "session.tool.success.1")
expect(success).toBeDefined()
const serialized = JSON.stringify(success)
expect(serialized.split(base64)).toHaveLength(2)
@ -94,7 +94,7 @@ test("provider-executed success retains its compatibility result", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
const success = published.find((event) => event.type === "session.next.tool.success.1")
const success = published.find((event) => event.type === "session.tool.success.1")
expect(success?.data).toHaveProperty("result")
})
@ -110,14 +110,13 @@ test("binary failure emits no success event", async () => {
}),
),
)
expect(published.some((event) => event.type === "session.next.tool.success.1")).toBe(false)
expect(published.some((event) => event.type === "session.next.tool.failed.1")).toBe(true)
expect(published.some((event) => event.type === "session.tool.success.1")).toBe(false)
expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true)
})
test("old success event data containing result still decodes", () => {
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
sessionID,
timestamp: Date.now(),
assistantMessageID: SessionMessage.ID.create(),
callID: "call-old",
structured: { type: "media", mime: "image/png" },
@ -133,6 +132,6 @@ test("step finish records settlement without publishing step ended", async () =>
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" })))
expect(published.some((event) => event.type === "session.next.step.ended.2")).toBe(false)
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
})

View file

@ -21,7 +21,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { QuestionV2 } from "@opencode-ai/core/question"
import { Form } from "@opencode-ai/core/form"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Snapshot } from "@opencode-ai/core/snapshot"
@ -39,6 +39,7 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { QuestionTool } from "@opencode-ai/core/tool/question"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
@ -260,7 +261,7 @@ const execution = Layer.effect(
Effect.gen(function* () {
const sessionRunner = yield* SessionRunner.Service
const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
})
return SessionExecution.Service.of({
active: coordinator.active,
@ -276,7 +277,7 @@ const it = testEffect(
LayerNode.group([
Database.node,
EventV2.node,
QuestionV2.node,
Form.node,
SessionProjector.node,
SessionStore.node,
AgentV2.node,
@ -423,6 +424,7 @@ const replaySessionProjection = (id: SessionV2.ID) =>
yield* events.replayAll(
recorded.map((event) => ({
id: event.id,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
@ -574,7 +576,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
)
const runner = yield* SessionRunner.Service
const fiber = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
const fiber = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
yield* Deferred.await(streamed)
yield* Fiber.interrupt(fiber)
expect(yield* session.context(sessionID)).toMatchObject([
@ -582,7 +584,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
{
type: "assistant",
finish: "error",
error: { type: "unknown", message: "Provider turn interrupted" },
error: { type: "unknown", message: "Step interrupted" },
content: [
kind === "tool input"
? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } }
@ -740,7 +742,6 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Moved, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
})
expect(
@ -848,7 +849,7 @@ describe("SessionRunnerLLM", () => {
yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, "session.next.context.updated.1"))
.where(eq(EventTable.type, "session.context.updated.1"))
.all()
.pipe(Effect.orDie),
).toHaveLength(1)
@ -1010,10 +1011,8 @@ describe("SessionRunnerLLM", () => {
response = []
yield* session.resume(sessionID)
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
yield* events.publish(SessionEvent.AgentSwitched, {
yield* events.publish(SessionEvent.AgentSelected, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: "reviewer",
})
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
@ -1039,10 +1038,8 @@ describe("SessionRunnerLLM", () => {
if (switched) return Effect.void
switched = true
return events
.publish(SessionEvent.AgentSwitched, {
.publish(SessionEvent.AgentSelected, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: "reviewer",
})
.pipe(Effect.asVoid)
@ -1069,10 +1066,8 @@ describe("SessionRunnerLLM", () => {
if (switched) return Effect.void
switched = true
return events
.publish(SessionEvent.ModelSwitched, {
.publish(SessionEvent.ModelSelected, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
.pipe(Effect.asVoid)
@ -1175,10 +1170,8 @@ describe("SessionRunnerLLM", () => {
systemBaseline = "Changed context"
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
yield* events.publish(SessionEvent.ModelSelected, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
systemBaseline = "Replacement context"
@ -1217,10 +1210,8 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
response = []
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
yield* events.publish(SessionEvent.ModelSelected, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
systemUnavailable = true
@ -1252,14 +1243,10 @@ describe("SessionRunnerLLM", () => {
const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(1),
reason: "manual",
})
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(2),
reason: "manual",
text: "summary",
recent: "",
@ -1482,14 +1469,10 @@ describe("SessionRunnerLLM", () => {
const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(1),
reason: "manual",
})
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(2),
reason: "manual",
text: "summary",
recent: "",
@ -1686,10 +1669,8 @@ describe("SessionRunnerLLM", () => {
toolExecutionsReady = 1
const run = yield* Effect.forkChild(session.resume(sessionID))
yield* Deferred.await(toolExecutionsStarted)
yield* events.publish(SessionEvent.ModelSwitched, {
yield* events.publish(SessionEvent.ModelSelected, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
systemBaseline = "Replacement context"
@ -2403,27 +2384,23 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
agent: "build",
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID,
callID: "call-interrupted",
name: "echo",
})
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID,
callID: "call-interrupted",
text: '{"text":"stale"}',
})
yield* events.publish(SessionEvent.Tool.Called, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID,
callID: "call-interrupted",
tool: "echo",
@ -2467,27 +2444,23 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
agent: "build",
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID,
callID: "call-hosted-interrupted",
name: "web_search",
})
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID,
callID: "call-hosted-interrupted",
text: '{"query":"stale"}',
})
yield* events.publish(SessionEvent.Tool.Called, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID,
callID: "call-hosted-interrupted",
tool: "web_search",
@ -2527,13 +2500,11 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
agent: "build",
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID,
callID: "call-pending-interrupted",
name: "echo",
@ -2578,7 +2549,7 @@ describe("SessionRunnerLLM", () => {
const events = yield* EventV2.Service
const defect = new Error("fail after prompt promotion")
let fail = true
yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void))
yield* events.project(SessionEvent.PromptPromoted, () => (fail ? Effect.die(defect) : Effect.void))
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover promoted input" }), resume: false })
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
@ -2603,7 +2574,9 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* events.listen((event) =>
event.type === SessionEvent.Prompted.type ? Effect.die("fail after prompt promotion commits") : Effect.void,
event.type === SessionEvent.PromptPromoted.type
? Effect.die("fail after prompt promotion commits")
: Effect.void,
)
yield* session.prompt({
sessionID,
@ -2850,19 +2823,17 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("interrupts runner continuation when a question is dismissed", () =>
it.effect("interrupts runner continuation when a question is cancelled", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
const questions = yield* QuestionV2.Service
yield* registry.register({
question: Tool.make({
description: "Ask the user",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: (_, context) =>
questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
execute: () => Effect.die(new QuestionTool.CancelledError()),
}),
})
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
@ -2879,12 +2850,6 @@ describe("SessionRunnerLLM", () => {
]
const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
let pending = yield* questions.list()
while (pending.length === 0) {
yield* Effect.yieldNow
pending = yield* questions.list()
}
yield* questions.reject(pending[0]!.id)
const exit = yield* Fiber.join(run)
expect(exit._tag).toBe("Failure")
@ -3011,9 +2976,9 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Interrupt provider" },
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Provider turn interrupted" } },
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Step interrupted" } },
])
expect(yield* recordedEventTypes(sessionID)).toContain("session.next.step.failed.2")
expect(yield* recordedEventTypes(sessionID)).toContain("session.step.failed.1")
yield* session.interrupt(sessionID)
}),
)
@ -3035,7 +3000,7 @@ describe("SessionRunnerLLM", () => {
]
const runner = yield* SessionRunner.Service
const run = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
const run = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
yield* Deferred.await(toolExecutionsStarted)
yield* Fiber.interrupt(run)
toolExecutionGate = undefined
@ -3046,7 +3011,7 @@ describe("SessionRunnerLLM", () => {
{
type: "assistant",
finish: "error",
error: { type: "unknown", message: "Provider turn interrupted" },
error: { type: "unknown", message: "Step interrupted" },
content: [
{
type: "tool",
@ -3057,8 +3022,8 @@ describe("SessionRunnerLLM", () => {
},
])
const eventTypes = yield* recordedEventTypes(sessionID)
expect(eventTypes).toContain("session.next.step.failed.2")
expect(eventTypes).not.toContain("session.next.step.ended.2")
expect(eventTypes).toContain("session.step.failed.1")
expect(eventTypes).not.toContain("session.step.ended.1")
}),
)
@ -3424,9 +3389,10 @@ describe("SessionRunnerLLM", () => {
streamStarted = undefined
response = [LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-1" })]
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"Duplicate text start: text-1",
)
const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))
expect(defect).toBeInstanceOf(Error)
if (!(defect instanceof Error)) return
expect(defect.message).toBe("Duplicate text start: text-1")
}),
)
@ -3468,9 +3434,10 @@ describe("SessionRunnerLLM", () => {
streamStarted = undefined
response = [LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })]
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"Tool input delta before start: call-1",
)
const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))
expect(defect).toBeInstanceOf(Error)
if (!(defect instanceof Error)) return
expect(defect.message).toBe("Tool input delta before start: call-1")
}),
)
})

View file

@ -0,0 +1,70 @@
import path from "path"
import { describe, expect } from "bun:test"
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/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
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 { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } 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 { 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 skills = Layer.mock(SkillV2.Service, {
list: () =>
Effect.succeed([
SkillV2.Info.make({
name: "effect",
description: "Effect guidance",
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
content: "Use Effect",
}),
]),
})
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// The skill endpoint only needs the location-scoped Skill service.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
skills as unknown as Layer.Layer<LocationServices>,
),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
[
[LocationServiceMap.node, locations],
[ProjectV2.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
describe("SessionV2.skill", () => {
it.effect("projects the caller-supplied message ID", () =>
Effect.gen(function* () {
const sessions = yield* SessionV2.Service
const session = yield* sessions.create({ location })
const id = SessionMessage.ID.make("msg_caller_skill")
yield* sessions.skill({ id, sessionID: session.id, skill: "effect", resume: false })
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
expect.objectContaining({ id, type: "skill", name: "effect", text: "Use Effect" }),
)
}),
)
})

View file

@ -86,17 +86,13 @@ const prompt = (sessionID: SessionV2.ID, text: string) =>
const messageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.PromptAdmitted, {
sessionID,
messageID,
timestamp: DateTime.makeUnsafe(0),
inputID: messageID,
prompt: Prompt.make({ text }),
delivery: "steer",
})
yield* events.publish(SessionEvent.Prompted, {
yield* events.publish(SessionEvent.PromptPromoted, {
sessionID,
messageID,
timestamp: DateTime.makeUnsafe(0),
prompt: Prompt.make({ text }),
delivery: "steer",
inputID: messageID,
})
})

View file

@ -51,7 +51,6 @@ describe("Tool.Progress", () => {
yield* service.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
timestamp,
agent: "build",
model,
})
@ -69,14 +68,12 @@ describe("Tool.Progress", () => {
Effect.gen(function* () {
yield* service.publish(SessionEvent.Tool.Input.Started, {
sessionID,
timestamp,
assistantMessageID,
callID,
name: "bash",
})
yield* service.publish(SessionEvent.Tool.Called, {
sessionID,
timestamp,
assistantMessageID,
callID,
tool: "bash",
@ -92,7 +89,6 @@ describe("Tool.Progress", () => {
yield* service.publish(SessionEvent.Tool.Progress, {
sessionID,
timestamp,
assistantMessageID,
callID: "call-success",
structured: { phase: "checkpoint" },
@ -104,7 +100,6 @@ describe("Tool.Progress", () => {
const success = yield* service.publish(SessionEvent.Tool.Success, {
sessionID,
timestamp,
assistantMessageID,
callID: "call-success",
structured: { phase: "done" },
@ -118,7 +113,6 @@ describe("Tool.Progress", () => {
yield* start("call-failed")
yield* service.publish(SessionEvent.Tool.Progress, {
sessionID,
timestamp,
assistantMessageID,
callID: "call-failed",
structured: { phase: "checkpoint" },
@ -126,7 +120,6 @@ describe("Tool.Progress", () => {
})
const failed = yield* service.publish(SessionEvent.Tool.Failed, {
sessionID,
timestamp,
assistantMessageID,
callID: "call-failed",
error: { type: "unknown", message: "boom" },

View file

@ -22,14 +22,12 @@ import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Integration } from "@opencode-ai/schema/integration"
import { LLM } from "@opencode-ai/schema/llm"
import { Permission } from "@opencode-ai/schema/permission"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Pty } from "@opencode-ai/schema/pty"
import { Reference } from "@opencode-ai/schema/reference"
import { SessionTodo } from "@opencode-ai/schema/session-todo"
import { Skill } from "@opencode-ai/schema/skill"
import { AbsolutePath, DateTimeUtcFromMillis, optional, statics } from "@opencode-ai/schema/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { PluginV2 } from "@opencode-ai/core/plugin"
test("Core reuses the canonical shared schemas", async () => {
const [
@ -129,8 +127,6 @@ test("Core reuses the canonical shared schemas", async () => {
[corePermission.Ruleset, Permission.Ruleset],
[corePermissionV1.Event, PermissionV1.Event],
[coreProjectCopy.Event, ProjectDirectories.Event],
[PluginV2.ID, Plugin.ID],
[PluginV2.Event, Plugin.Event],
[corePty.Info, Pty.Info],
[corePty.Event, Pty.Event],
[coreProject.ID, Project.ID],
@ -148,8 +144,8 @@ test("Core reuses the canonical shared schemas", async () => {
[coreSessionInput.Admitted, SessionInput.Admitted],
[coreSessionMessage.ID, SessionMessage.ID],
[coreSessionMessage.UnknownError, SessionMessage.UnknownError],
[coreSessionMessage.AgentSwitched, SessionMessage.AgentSwitched],
[coreSessionMessage.ModelSwitched, SessionMessage.ModelSwitched],
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
[coreSessionMessage.ModelSelected, SessionMessage.ModelSelected],
[coreSessionMessage.User, SessionMessage.User],
[coreSessionMessage.Synthetic, SessionMessage.Synthetic],
[coreSessionMessage.System, SessionMessage.System],

View file

@ -1,36 +1,42 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { Effect } from "effect"
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/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { tmpdir } from "./fixture/tmpdir"
const base = "https://skills.example.test/catalog/"
type Fixture = {
tmp: Awaited<ReturnType<typeof tmpdir>>
server: Bun.Server<undefined>
state: {
skills: unknown[]
files: Record<string, string>
requests: string[]
}
base: string
}
async function pull(skills: unknown[], files: Record<string, string> = {}, cache?: Awaited<ReturnType<typeof tmpdir>>) {
const tmp = cache ?? (await tmpdir())
const requests: string[] = []
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => requests.push(request.url)).pipe(
Effect.map(() => {
const body = request.url === `${base}index.json` ? JSON.stringify({ skills }) : files[request.url]
return HttpClientResponse.fromWeb(
request,
new Response(body ?? "Not Found", { status: body === undefined ? 404 : 200 }),
)
}),
),
),
)
async function pull(skills: unknown[], files: Record<string, string> = {}, fixture?: Fixture) {
const state = fixture?.state ?? { skills, files, requests: [] }
state.skills = skills
state.files = files
state.requests = []
const server =
fixture?.server ??
Bun.serve({
port: 0,
fetch(request) {
state.requests.push(request.url)
const pathname = new URL(request.url).pathname
const body = pathname === "/catalog/index.json" ? JSON.stringify({ skills: state.skills }) : state.files[pathname]
return new Response(body ?? "Not Found", { status: body === undefined ? 404 : 200 })
},
})
const tmp = fixture?.tmp ?? (await tmpdir())
const base = fixture?.base ?? new URL("/catalog/", server.url).href
const skillDiscoveryLayer = AppNodeBuilder.build(SkillDiscovery.node, [
[LayerNodePlatform.httpClient, http],
[Global.node, Global.layerWith({ cache: tmp.path })],
])
const directories = await Effect.runPromise(
@ -38,7 +44,12 @@ async function pull(skills: unknown[], files: Record<string, string> = {}, cache
return yield* (yield* SkillDiscovery.Service).pull(base)
}).pipe(Effect.provide(skillDiscoveryLayer)),
)
return { tmp, requests, directories }
return { tmp, server, state, base, requests: state.requests, directories }
}
async function dispose(fixture: Fixture) {
await fixture.server.stop(true)
await fixture.tmp[Symbol.asyncDispose]()
}
describe("SkillDiscovery.pull", () => {
@ -46,10 +57,10 @@ describe("SkillDiscovery.pull", () => {
const result = await pull([{ name: "../outside", files: ["SKILL.md"] }])
try {
expect(result.directories).toEqual([])
expect(result.requests).toEqual([`${base}index.json`])
expect(result.requests).toEqual([`${result.base}index.json`])
expect(await fs.readdir(result.tmp.path)).toEqual([])
} finally {
await result.tmp[Symbol.asyncDispose]()
await dispose(result)
}
})
@ -57,10 +68,10 @@ describe("SkillDiscovery.pull", () => {
const result = await pull([{ name: "deploy", files: ["SKILL.md", "../outside.md"] }])
try {
expect(result.directories).toEqual([])
expect(result.requests).toEqual([`${base}index.json`])
expect(result.requests).toEqual([`${result.base}index.json`])
expect(await fs.readdir(result.tmp.path)).toEqual([])
} finally {
await result.tmp[Symbol.asyncDispose]()
await dispose(result)
}
})
@ -68,10 +79,10 @@ describe("SkillDiscovery.pull", () => {
const result = await pull([{ name: "deploy", files: ["SKILL.md", "/tmp/outside.md"] }])
try {
expect(result.directories).toEqual([])
expect(result.requests).toEqual([`${base}index.json`])
expect(result.requests).toEqual([`${result.base}index.json`])
expect(await fs.readdir(result.tmp.path)).toEqual([])
} finally {
await result.tmp[Symbol.asyncDispose]()
await dispose(result)
}
})
@ -79,87 +90,87 @@ describe("SkillDiscovery.pull", () => {
const result = await pull([{ name: "deploy", files: ["SKILL.md", "https://evil.example.test/outside.md"] }])
try {
expect(result.directories).toEqual([])
expect(result.requests).toEqual([`${base}index.json`])
expect(result.requests).toEqual([`${result.base}index.json`])
expect(await fs.readdir(result.tmp.path)).toEqual([])
} finally {
await result.tmp[Symbol.asyncDispose]()
await dispose(result)
}
})
test("downloads safe nested files under the skill root", async () => {
const result = await pull([{ name: "deploy", files: ["SKILL.md", "references/guide.md"] }], {
[`${base}deploy/SKILL.md`]: "# Deploy",
[`${base}deploy/references/guide.md`]: "# Guide",
"/catalog/deploy/SKILL.md": "# Deploy",
"/catalog/deploy/references/guide.md": "# Guide",
})
try {
expect(result.directories).toHaveLength(1)
expect(result.requests.toSorted()).toEqual(
[`${base}index.json`, `${base}deploy/SKILL.md`, `${base}deploy/references/guide.md`].toSorted(),
[
`${result.base}index.json`,
`${result.base}deploy/SKILL.md`,
`${result.base}deploy/references/guide.md`,
].toSorted(),
)
expect(await fs.readFile(path.join(result.directories[0], "SKILL.md"), "utf8")).toBe("# Deploy")
expect(await fs.readFile(path.join(result.directories[0], "references", "guide.md"), "utf8")).toBe("# Guide")
} finally {
await result.tmp[Symbol.asyncDispose]()
await dispose(result)
}
})
test("refreshes cached files when the version changes", async () => {
const tmp = await tmpdir()
const first = await pull(
[{ name: "deploy", version: "1", files: ["SKILL.md"] }],
{ "/catalog/deploy/SKILL.md": "# Old" },
)
try {
const first = await pull(
[{ name: "deploy", version: "1", files: ["SKILL.md"] }],
{
[`${base}deploy/SKILL.md`]: "# Old",
},
tmp,
)
const second = await pull(
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
{
[`${base}deploy/SKILL.md`]: "# New",
},
tmp,
{ "/catalog/deploy/SKILL.md": "# New" },
first,
)
expect(await fs.readFile(path.join(first.directories[0], "SKILL.md"), "utf8")).toBe("# New")
expect(second.requests).toContain(`${base}deploy/SKILL.md`)
expect(second.requests).toContain(`${first.base}deploy/SKILL.md`)
const third = await pull(
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
{ [`${base}deploy/SKILL.md`]: "# Ignored" },
tmp,
{ "/catalog/deploy/SKILL.md": "# Ignored" },
first,
)
expect(third.requests).toEqual([`${base}index.json`])
expect(third.requests).toEqual([`${first.base}index.json`])
} finally {
await tmp[Symbol.asyncDispose]()
await dispose(first)
}
})
test("publishes complete updates and removes stale files", async () => {
const tmp = await tmpdir()
const first = await pull(
[{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }],
{
"/catalog/deploy/SKILL.md": "# Old",
"/catalog/deploy/old.md": "old reference",
},
)
try {
const first = await pull(
[{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }],
{
[`${base}deploy/SKILL.md`]: "# Old",
[`${base}deploy/old.md`]: "old reference",
},
tmp,
)
const root = first.directories[0]
await pull(
[{ name: "deploy", version: "2", files: ["SKILL.md", "missing.md"] }],
{ [`${base}deploy/SKILL.md`]: "# Partial" },
tmp,
{ "/catalog/deploy/SKILL.md": "# Partial" },
first,
)
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# Old")
expect(await fs.readFile(path.join(root, "old.md"), "utf8")).toBe("old reference")
await pull([{ name: "deploy", version: "3", files: ["SKILL.md"] }], { [`${base}deploy/SKILL.md`]: "# New" }, tmp)
await pull(
[{ name: "deploy", version: "3", files: ["SKILL.md"] }],
{ "/catalog/deploy/SKILL.md": "# New" },
first,
)
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# New")
expect(await Bun.file(path.join(root, "old.md")).exists()).toBe(false)
} finally {
await tmp[Symbol.asyncDispose]()
await dispose(first)
}
})
})

View file

@ -10,7 +10,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SkillV2 } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@ -206,7 +206,7 @@ metadata:
waitForSkillUpdate(),
({ deferred }) =>
events
.publish(FileSystemWatcher.Event.Updated, { file, event: "change" })
.publish(FileSystem.Event.Changed, { file, event: "change" })
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber),
)

View file

@ -16,8 +16,15 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const applyPatchToolNode = makeLocationNode({
name: "test/apply-patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(ApplyPatchTool.Plugin)),
deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
})
const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -101,7 +108,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
ToolRegistry.toolsNode,
LocationMutation.node,
FileMutation.node,
ApplyPatchTool.node,
applyPatchToolNode,
]),
[
[FSUtil.node, filesystem],

View file

@ -17,8 +17,15 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { EditTool } from "@opencode-ai/core/tool/edit"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const editToolNode = makeLocationNode({
name: "test/edit-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
})
const sessionID = SessionV2.ID.make("ses_edit_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -91,7 +98,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
ToolRegistry.toolsNode,
LocationMutation.node,
FileMutation.node,
EditTool.node,
editToolNode,
]),
[
[FSUtil.node, filesystem],

View file

@ -1,19 +1,20 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer } from "effect"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Form } from "@opencode-ai/core/form"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { QuestionV2 } from "@opencode-ai/core/question"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { QuestionTool } from "@opencode-ai/core/tool/question"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_question_tool_test")
const assertions: PermissionV2.AssertInput[] = []
let captured: QuestionV2.AskInput | undefined
let captured: Form.CreateInput | undefined
let reject = false
let deny = false
const capturedInput = () => captured
@ -31,22 +32,38 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const question = Layer.succeed(
QuestionV2.Service,
QuestionV2.Service.of({
ask: (input: QuestionV2.AskInput) =>
const form = Layer.succeed(
Form.Service,
Form.Service.of({
ask: (input: Form.CreateInput) =>
Effect.sync(() => {
captured = input
}).pipe(Effect.andThen(reject ? Effect.fail(new QuestionV2.RejectedError()) : Effect.succeed([["Build"], []]))),
reply: () => Effect.die("unused"),
reject: () => Effect.die("unused"),
}).pipe(
Effect.andThen(
Effect.sync(
(): Form.TerminalState =>
reject ? { status: "cancelled" } : { status: "answered", answer: { q0: "Build", q1: ["Dev"] } },
),
),
),
create: () => Effect.die("unused"),
get: () => Effect.die("unused"),
list: () => Effect.die("unused"),
state: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
}),
)
const questionToolNode = makeLocationNode({
name: "test/question-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(QuestionTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, Form.node],
})
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, questionToolNode]), [
[PermissionV2.node, permission],
[QuestionV2.node, question],
[Form.node, form],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
)
@ -88,6 +105,12 @@ describe("QuestionTool", () => {
question: "Which environment?",
header: "Environment",
options: [{ label: "Dev", description: "Development" }],
multiple: true,
},
{
question: "Anything else?",
header: "Optional",
options: [],
},
]
@ -102,14 +125,14 @@ describe("QuestionTool", () => {
result: {
type: "text",
value:
'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.',
'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.',
},
output: {
structured: { answers: [["Build"], []] },
structured: { answers: [["Build"], ["Dev"], []] },
content: [
{
type: "text",
text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.',
text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.',
},
],
},
@ -117,8 +140,34 @@ describe("QuestionTool", () => {
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
expect(capturedInput()).toEqual({
sessionID,
questions,
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
mode: "form",
fields: [
{
key: "q0",
title: "Action",
description: "What should happen?",
options: [{ value: "Build", label: "Build", description: "Build it" }],
custom: true,
type: "string",
},
{
key: "q1",
title: "Environment",
description: "Which environment?",
options: [{ value: "Dev", label: "Dev", description: "Development" }],
custom: true,
type: "multiselect",
},
{
key: "q2",
title: "Optional",
description: "Anything else?",
options: [],
custom: true,
type: "string",
},
],
})
}),
)
@ -137,8 +186,9 @@ describe("QuestionTool", () => {
})
expect(capturedInput()).toEqual({
sessionID,
questions: [],
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
mode: "form",
fields: [],
})
}),
)
@ -157,6 +207,11 @@ describe("QuestionTool", () => {
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
expect(error).toBeInstanceOf(QuestionTool.CancelledError)
expect(error).toHaveProperty("message", "The user dismissed this question")
}
}),
)
})

View file

@ -19,8 +19,25 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ReadTool } from "@opencode-ai/core/tool/read"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const readToolNode = makeLocationNode({
name: "test/read-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(ReadTool.Plugin)),
deps: [
ToolRegistry.toolsNode,
ReadToolFileSystem.node,
LocationMutation.node,
Image.node,
PermissionV2.node,
SessionInstructions.node,
FSUtil.node,
Location.node,
],
})
const assertions: PermissionV2.AssertInput[] = []
const missingPath = "__missing_read_target__.txt"
@ -130,7 +147,7 @@ const unavailableImage = Layer.succeed(
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
)
const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, ReadTool.node]), [
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, readToolNode]), [
[ReadToolFileSystem.node, reader],
[PermissionV2.node, permission],
[Config.node, config],

View file

@ -0,0 +1,87 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { GlobTool } from "@opencode-ai/core/tool/glob"
import { GrepTool } from "@opencode-ai/core/tool/grep"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
})
const grepToolNode = makeLocationNode({
name: "test/grep-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
})
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: () => Effect.void,
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const sessionID = SessionV2.ID.make("ses_search_tool_test")
const withTools = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, globToolNode, grepToolNode]), [
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
),
)
const call = (name: "glob" | "grep", input: unknown) => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id: `call-${name}`, name, input },
})
const it = testEffect(Layer.empty)
describe("search tools", () => {
for (const name of ["glob", "grep"] as const) {
it.live(`${name} reports a missing search path`, () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
withTools(tmp.path, (registry) =>
Effect.gen(function* () {
const result = yield* executeTool(
registry,
call(name, { path: "missing", pattern: name === "glob" ? "*" : "needle" }),
)
expect(result).toEqual({ type: "error", value: "Search path does not exist: missing" })
}),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
}
})

View file

@ -79,27 +79,23 @@ const executionNode = makeGlobalNode({
yield* events.publish(SessionEvent.Step.Started, {
sessionID: id,
assistantMessageID,
timestamp: yield* DateTime.now,
agent: session.agent ?? AgentV2.ID.make("code"),
model: sessionModel,
})
yield* events.publish(SessionEvent.Text.Started, {
sessionID: id,
assistantMessageID,
timestamp: yield* DateTime.now,
textID,
})
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: id,
assistantMessageID,
timestamp: yield* DateTime.now,
textID,
text: "ok",
})
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: id,
assistantMessageID,
timestamp: yield* DateTime.now,
finish: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
@ -175,9 +171,11 @@ const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.I
})
const locations = yield* LocationServiceMap.Service
const locationLayer = locations.get(location)
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locationLayer))
yield* waitForTool(registry, ShellTool.name)
return yield* body(registry).pipe(Effect.provide(locationLayer))
return yield* Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, ShellTool.name)
return yield* body(registry)
}).pipe(Effect.provide(locationLayer), Effect.ensuring(locations.invalidate(location)))
})
describe("ShellTool", () => {
@ -482,9 +480,13 @@ describe("ShellTool", () => {
const structured = settled.output?.structured as Record<string, unknown> | undefined
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
expect(settled.output?.structured).toMatchObject({ truncated: false })
expect(settled.output?.content[0]).toMatchObject({
expect(settled.output?.content[0]).toEqual({
type: "text",
text: expect.stringContaining("running in the background"),
text: "The command was moved to the background.",
})
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("DO NOT sleep, poll"),
})
expect(shellID).toStartWith("sh_")

View file

@ -13,7 +13,15 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const skillToolNode = makeLocationNode({
name: "test/skill-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(SkillTool.Plugin)),
deps: [ToolRegistry.toolsNode, FSUtil.node, SkillV2.node, PermissionV2.node],
})
const sessionID = SessionV2.ID.make("ses_skill_tool_test")
@ -66,7 +74,7 @@ describe("SkillTool", () => {
}),
)
const skillToolLayer = AppNodeBuilder.build(
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, SkillTool.node]),
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, skillToolNode]),
[
[PermissionV2.node, permission],
[SkillV2.node, skills],

View file

@ -53,27 +53,23 @@ const executionNode = makeGlobalNode({
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
agent: AgentV2.ID.make("reviewer"),
model: childModel,
})
yield* events.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
textID,
})
yield* events.publish(SessionEvent.Text.Ended, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
textID,
text: childText,
})
yield* events.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
finish: "stop",
cost: 0,
tokens,

View file

@ -15,7 +15,14 @@ import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const todoWriteToolNode = makeLocationNode({
name: "test/todowrite-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(TodoWriteTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, SessionTodo.node],
})
const sessionID = SessionV2.ID.make("ses_todowrite_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -43,7 +50,7 @@ const it = testEffect(
SessionTodo.node,
ToolRegistry.node,
ToolRegistry.toolsNode,
TodoWriteTool.node,
todoWriteToolNode,
]),
[
[PermissionV2.node, permission],

View file

@ -10,8 +10,15 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const webFetchToolNode = makeLocationNode({
name: "test/webfetch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WebFetchTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient],
})
const sessionID = SessionV2.ID.make("ses_webfetch_test")
const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
@ -40,7 +47,7 @@ const permission = Layer.succeed(
}),
)
const toolLayer = (replacements: LayerNode.Replacements = []) =>
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebFetchTool.node]), [
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, webFetchToolNode]), [
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
...replacements,

View file

@ -9,8 +9,15 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const webSearchToolNode = makeLocationNode({
name: "test/websearch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode],
})
const sessionID = SessionV2.ID.make("ses_websearch_test")
const payload = (text: string) =>
@ -125,7 +132,7 @@ const websearchConfig = Layer.succeed(
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, WebSearchTool.node]),
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]),
[
[PermissionV2.node, permission],
[LayerNodePlatform.httpClient, http],

View file

@ -17,8 +17,15 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { WriteTool } from "@opencode-ai/core/tool/write"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const writeToolNode = makeLocationNode({
name: "test/write-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, PermissionV2.node],
})
const sessionID = SessionV2.ID.make("ses_write_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -75,7 +82,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
ToolRegistry.toolsNode,
LocationMutation.node,
FileMutation.node,
WriteTool.node,
writeToolNode,
]),
[
[FSUtil.node, filesystem],