Compare commits

..
122 changed files with 9958 additions and 1089 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
- The default branch in this repo is `v2`.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
- Default new branches and worktrees to `v2`, or `origin/v2` when the local `v2` ref is unavailable, and default pull requests to target `v2`. Use another base or target branch when the requester explicitly instructs it.
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
## Live V2 TUI Testing
@@ -107,6 +107,8 @@ for (const position of ["top", "bottom"] as const) {
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
const status = page.getByRole("dialog", { name: "Status", exact: true })
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await status.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(status.getByText("opencode.json", { exact: true })).toBeVisible()
await status.getByRole("button", { name: "Close", exact: true }).click()
await expect(status).toBeHidden()
await expect(more).toBeFocused()
@@ -0,0 +1,103 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/Projects/extensions-demo"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const session = {
id: "ses_project_extensions",
title: "Existing session",
directory,
projectID: "proj_extensions_demo",
time: { created: 1700000000000, updated: 1700000000000 },
}
test.use({ viewport: { width: 1440, height: 1000 }, colorScheme: "dark" })
test("project Extensions stays inside settings while plugins load", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: session.projectID,
canonical: directory,
name: "Extensions demo",
vcs: "git",
time: session.time,
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [session],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ server, sessionID, directory }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ server, sessionID: session.id, directory },
)
const href = `/server/${base64Encode(server)}/session/${session.id}`
await page.goto(href)
await expect(page.getByRole("heading", { name: session.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByText("Extensions demo", { exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox", { name: "Name", exact: true })).toBeFocused()
const globalPlugins = Promise.withResolvers<void>()
const projectPlugins = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/plugin",
async (route) => {
const project = new URL(route.request().url()).searchParams.get("location[directory]")
await (project ? projectPlugins : globalPlugins).promise
await route.fulfill({
json: {
location: project ? { directory: project } : {},
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", target: id },
state: { status: "active" },
features: { server: true },
})),
},
})
},
)
const requested = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/plugin" && url.searchParams.get("location[directory]") === directory
})
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
await requested
await expect(page).toHaveURL(href)
await expect(dialog.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings).toBeVisible()
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(dialog.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
globalPlugins.resolve()
await dialog.getByRole("tab", { name: "Scripts", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Scripts", exact: true })).toBeVisible()
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
projectPlugins.resolve()
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(dialog.getByText("project-plugin", { exact: true })).toBeVisible()
await dialog.getByRole("button", { name: "Shared with all projects 1", exact: true }).click()
await expect(dialog.getByText("shared-plugin", { exact: true })).toBeVisible()
await expect(page).toHaveURL(href)
await page.keyboard.press("Escape")
await expect(dialog).toBeHidden()
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
})
@@ -68,20 +68,41 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
refresh.resolve()
})
test("extensions opens without waiting for MCPs", async ({ page }) => {
test("extensions opens without waiting for MCPs or plugins", async ({ page }) => {
const mcps = Promise.withResolvers<void>()
const plugins = Promise.withResolvers<void>()
await page.route("**/api/mcp", async (route) => {
await mcps.promise
await route.fulfill({
json: { location: { directory }, data: [{ name: "demo-mcp", status: { status: "connected" } }] },
})
})
await page.route("**/api/plugin", async (route) => {
await plugins.promise
await route.fulfill({
json: {
location: { directory },
data: [
{
id: "demo-plugin",
source: { type: "package", target: "demo-plugin" },
state: { status: "active" },
features: { server: true },
},
],
},
})
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/mcp")
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
await settings.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(settings.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
plugins.resolve()
await expect(settings.getByText("demo-plugin", { exact: true })).toBeVisible()
mcps.resolve()
await settings.getByRole("tab", { name: "MCPs", exact: true }).click()
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test"
import type { PluginInfo } from "@opencode-ai/client"
import { pluginLabels } from "./plugin"
describe("pluginLabels", () => {
test("omits built-in plugins", () => {
const plugins: PluginInfo[] = [
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "package-plugin",
source: { type: "package", target: "example" },
state: { status: "active" },
features: { server: true },
},
{
id: "local-plugin",
source: { type: "local", path: "/tmp/plugin.ts" },
state: { status: "active" },
features: { server: true },
},
{ id: "sdk-plugin", source: { type: "sdk" }, state: { status: "active" }, features: { server: true } },
]
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
})
})
+3
View File
@@ -85,6 +85,8 @@ test("migrates tui and kv config into cli.json", async () => {
session_delete: false,
"dialog.select.next": "ctrl+n",
},
plugin: [["example", { mode: "safe" }]],
plugin_enabled: { disabled: false },
leader_timeout: 500,
scroll_speed: 2,
scroll_acceleration: { enabled: true },
@@ -133,6 +135,7 @@ test("migrates tui and kv config into cli.json", async () => {
"session.delete": false,
"dialog.select.next": "ctrl+n",
},
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
leader: { timeout: 500 },
scroll: { speed: 2, acceleration: true },
attention: { sound_pack: "custom.pack" },
+27
View File
@@ -0,0 +1,27 @@
import { expect, test } from "bun:test"
import path from "node:path"
import { parse } from "jsonc-parser"
import { configurationTarget, writePluginConfig } from "../src/commands/handlers/plugin/add"
import { tmpdir } from "./fixture/tmpdir"
test("routes packages according to their exported runtimes", () => {
expect(configurationTarget("server.js", "tui.js")).toBe("server")
expect(configurationTarget("server.js", undefined)).toBe("server")
expect(configurationTarget(undefined, "tui.js")).toBe("tui")
expect(configurationTarget(undefined, undefined)).toBeUndefined()
})
test("adds a package to global plugin config without replacing unrelated settings", async () => {
await using directory = await tmpdir()
const file = path.join(directory.path, "opencode.jsonc")
await Bun.write(file, '{\n // retained\n "model": "provider/model",\n "plugins": ["first"]\n}\n')
expect(await writePluginConfig(file, "second@1.0.0")).toBe(true)
expect(await writePluginConfig(file, "second@1.0.0")).toBe(false)
const text = await Bun.file(file).text()
expect(text).toContain("// retained")
expect(parse(text)).toEqual({
model: "provider/model",
plugins: ["first", "second@1.0.0"],
})
})
+18
View File
@@ -0,0 +1,18 @@
import { EOL } from "node:os"
import { expect, test } from "bun:test"
import { displayVersion, format, type Item } from "../src/commands/handlers/plugin/inventory"
test("formats server and TUI package update status", () => {
const items: Item[] = [
{ runtime: "Server", target: "server", name: "server.plugin", version: "1.2.3", outdated: true },
{ runtime: "TUI", target: "tui", name: "tui", version: "2.0.0", outdated: false },
]
expect(format(items)).toBe(
["Server", " server.plugin 1.2.3 (update available)", "TUI", " tui 2.0.0 (current)"].join(EOL),
)
})
test("shortens Git revisions", () => {
expect(displayVersion("dadba138b7088d61f937869bbc1ef34b1f91188d")).toBe("dadba13")
})
+85
View File
@@ -0,0 +1,85 @@
import { expect, test } from "bun:test"
import { EOL } from "node:os"
import { format } from "../src/commands/handlers/plugin/list"
test("lists plugin IDs, installed versions, and sources without runtime sections", () => {
expect(
format(
[
{ id: "opencode.agent", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "acme.dual",
source: { type: "package", target: "acme-plugin@1.0.0", version: "1.0.0" },
state: { status: "active" },
features: { server: true, tui: true },
},
{
source: { type: "package", target: "broken-plugin" },
state: { status: "failed", error: "broken" },
features: { server: true },
},
{
id: "local.dual",
source: { type: "local", path: "/tmp/local/index.ts" },
state: { status: "active" },
features: { server: true, tui: true },
},
],
[
{ target: "tui-only", version: "2.0.0" },
{ target: "/tmp/local.ts", version: "local" },
{ target: "acme-plugin@1.0.0", version: "1.0.0" },
{ target: "/tmp/local/tui.ts", version: "local" },
],
)
.split(EOL)
.map((line) => line.split(/\s{2,}/)),
).toEqual([
["ID", "VERSION", "SOURCE"],
["-", "local", "/tmp/local.ts"],
["-", "-", "broken-plugin"],
["-", "2.0.0", "tui-only"],
["acme.dual", "1.0.0", "acme-plugin@1.0.0"],
["local.dual", "local", "/tmp/local/index.ts"],
])
})
test("includes builtins when requested", () => {
expect(
format(
[
{
id: "opencode.agent",
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
],
[],
true,
)
.split(EOL)
.map((line) => line.split(/\s{2,}/)),
).toEqual([
["ID", "VERSION", "SOURCE"],
["opencode.agent", "-", "builtin"],
])
})
test("shortens Git commits and leaves absent IDs unknown", () => {
expect(
format(
[
{
source: { type: "package", target: "github:acme/plugin", version: "a".repeat(40) },
state: { status: "active" },
features: { server: true },
},
],
[],
)
.split(EOL)[1]
.split(/\s{2,}/),
).toEqual(["-", "aaaaaaa", "github:acme/plugin"])
expect(format([], [])).toBe("")
})
+20
View File
@@ -0,0 +1,20 @@
import { expect, test } from "bun:test"
import path from "node:path"
import { parse } from "jsonc-parser"
import { removePluginConfig } from "../src/commands/handlers/plugin/remove"
import { tmpdir } from "./fixture/tmpdir"
test("removes string and object package entries without replacing unrelated settings", async () => {
await using directory = await tmpdir()
const file = path.join(directory.path, "opencode.jsonc")
await Bun.write(
file,
'{\n // retained\n "model": "provider/model",\n "plugins": ["remove-me", { "package": "remove-me", "options": {} }, "keep-me"]\n}\n',
)
expect(await removePluginConfig(file, "remove-me")).toBe(true)
expect(await removePluginConfig(file, "remove-me")).toBe(false)
const text = await Bun.file(file).text()
expect(text).toContain("// retained")
expect(parse(text)).toEqual({ model: "provider/model", plugins: ["keep-me"] })
})
+11 -11
View File
@@ -103,16 +103,12 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>()
yield* announce("version-mismatch", service.version)
if (!service.legacy && service.state === "ready")
yield* Effect.tryPromise(() =>
PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout),
)
else {
if (!service.legacy)
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
}
yield* terminate(service.info, options, timing).pipe(Effect.ignore)
if (!service.legacy && service.state !== "ready")
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
yield* stop({
file: options.file,
pty: !service.legacy && service.state === "ready" ? "handoff" : "clear",
}).pipe(Effect.ignore)
lastSpawn = 0
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
@@ -145,8 +141,12 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
/** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
const info = yield* read(options.file)
if (options.pty === "handoff" && info !== undefined)
yield* Effect.tryPromise(() =>
PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout),
)
else yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
})
+9 -9
View File
@@ -84,14 +84,12 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
announce("version-mismatch", service.version)
if (!service.legacy && service.state === "ready")
await PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout)
else {
if (!service.legacy)
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
await PtyHandoff.clear(options.file ?? fallback())
}
await terminate(service.info, options, timing).catch(() => undefined)
if (!service.legacy && service.state !== "ready")
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
await stop({
file: options.file,
pty: !service.legacy && service.state === "ready" ? "handoff" : "clear",
}).catch(() => undefined)
lastSpawn = 0
}
} else {
@@ -119,8 +117,10 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
/** Stop the registered local service. */
export async function stop(options: StopOptions = {}) {
await PtyHandoff.clear(options.file ?? fallback())
const info = await read(options.file)
if (options.pty === "handoff" && info !== undefined)
await PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
else await PtyHandoff.clear(options.file ?? fallback())
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
}
+2
View File
@@ -38,6 +38,8 @@ export type EnsureOptions = DiscoverOptions & {
export type StopOptions = {
/** Absolute registration file path. Defaults to the XDG state directory. */
readonly file?: string
/** How to handle persistent terminals before stopping the service. */
readonly pty?: "clear" | "handoff"
}
/** Contents of the local service registration file. */
+2
View File
@@ -45,6 +45,7 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
sessionID: "ses_test",
key: "review-notes",
})
const emptyRpcOutput: Awaited<ReturnType<typeof promiseClient.rpc.call>> = {}
void [
effectSession,
@@ -54,6 +55,7 @@ void [
promiseList,
promisePut,
promiseRemove,
emptyRpcOutput,
exactVersion,
compatibleVersion,
]
+1 -23
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { Context, DateTime, Effect, Stream } from "effect"
import { DateTime, Effect, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import {
AbsolutePath,
@@ -135,28 +135,6 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
})
test("shared event source runs with the Effect context captured by make", async () => {
const connected = { id: "evt_connected", type: "server.connected", data: {} }
const Token = Context.Reference("test/effect/token", { defaultValue: () => "missing" })
const httpClient = HttpClient.make((request) =>
Effect.gen(function* () {
const token = yield* Token
expect(token).toBe("captured")
return HttpClientResponse.fromWeb(
request,
new Response(`data: ${JSON.stringify(connected)}\n\n`, { headers: { "content-type": "text/event-stream" } }),
)
}),
)
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.provideService(Token, "captured"),
),
)
expect((await Effect.runPromise(Stream.runCollect(client.event.subscribe())))[0]).toEqual(connected)
})
test("event.subscribe terminates on Effect protocol decode failures", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
+495
View File
@@ -0,0 +1,495 @@
import { expect, test } from "bun:test"
import { Rpc } from "@opencode-ai/schema/rpc"
import { Cause, Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { OpenCode } from "../src/effect/index"
const definition = Rpc.define({
id: "example",
methods: {
count: {
input: Schema.Struct({ count: Schema.FiniteFromString }),
output: Schema.FiniteFromString,
errors: { too_large: Schema.Struct({ limit: Schema.FiniteFromString }) },
},
echo: { input: Schema.Json, output: Schema.Json },
empty: { input: Schema.Undefined, output: Schema.Undefined },
raw: { input: { type: "string" }, output: { type: "number" } },
},
events: {
progress: { schema: Schema.Struct({ count: Schema.FiniteFromString }) },
message: { schema: Schema.Struct({ text: Schema.String }) },
},
})
const connected = { id: "evt_connected", type: "server.connected", data: {} }
function rpcEvent(count: unknown, directory = "/project/one", rpcID = "example", name = "progress") {
return {
id: "evt_progress",
created: 123,
type: `rpc.${rpcID}.${name}`,
location: { directory },
metadata: { origin: "test" },
data: { count },
}
}
function eventSource() {
const requests: HttpClientRequest.HttpClientRequest[] = []
const opened = Promise.withResolvers<{
controller: ReadableStreamDefaultController<Uint8Array>
signal: AbortSignal
}>()
const cancelled = Promise.withResolvers<void>()
return {
requests,
opened: opened.promise,
cancelled: cancelled.promise,
async push(event: unknown) {
const source = await opened.promise
source.controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`))
},
httpClient: HttpClient.make((request, _url, signal) => {
requests.push(request)
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
opened.resolve({ controller, signal })
},
cancel() {
cancelled.resolve()
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
),
)
}),
}
}
test("Effect RPC calls retain encoded inputs, decode outputs, and preserve raw native RPC calls", async () => {
const requests: Array<{ url: string; body: unknown }> = []
const httpClient = HttpClient.make((request) => {
const body = request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : {}
requests.push({ url: request.url, body })
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({
output: request.url.endsWith("/count") ? "42" : request.url.endsWith("/raw") ? 7 : body.input,
}),
),
)
})
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: new URL("http://localhost:3000") })
const rpc = client.rpc(definition)
const count = yield* rpc.count({ count: "2" })
const primitives = yield* Effect.forEach([null, false, 0, "hello", [1, "two"]], (value) => rpc.echo(value))
const empty = yield* rpc.empty()
const raw = yield* rpc.raw("input")
const native = yield* client.rpc.call({ rpcID: "example", method: "count", input: null })
expect(Object.keys(rpc.events)).toEqual(["subscribe"])
return { count, primitives, empty, raw, native }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result).toEqual({
count: 42,
primitives: [null, false, 0, "hello", [1, "two"]],
empty: undefined,
raw: 7,
native: { output: "42" },
})
expect(requests[0]).toEqual({ url: "http://localhost:3000/api/rpc/example/count", body: { input: { count: "2" } } })
expect(requests.find((request) => request.url.endsWith("/empty"))?.body).toEqual({})
})
test("Effect RPC trusts server-side Standard Schema transforms for outputs and events", async () => {
const validations: unknown[] = []
const standard = {
"~standard": {
version: 1 as const,
vendor: "fixture",
validate(value: unknown) {
validations.push(value)
return { value: String(value) + " transformed" }
},
},
}
const service = Rpc.define({
id: "standard",
methods: { transform: { input: standard, output: standard } },
events: {
transformed: {
schema: {
"~standard": {
version: 1 as const,
vendor: "fixture",
validate(value: unknown) {
validations.push(value)
return { value: { text: String(value) + " transformed" } }
},
},
},
},
},
})
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
request.url.endsWith("/api/event")
? new Response(
`data: ${JSON.stringify({ ...rpcEvent(1), type: "rpc.standard.transformed", data: { text: "done" } })}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
)
: Response.json({ output: "done" }),
),
),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const rpc = client.rpc(service)
return {
output: yield* rpc.transform("input"),
events: yield* Stream.runCollect(rpc.events.subscribe("transformed")),
}
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result.output).toBe("done")
expect(result.events[0].data).toEqual({ text: "done" })
expect(validations).toEqual([])
})
test("Effect RPC validates decoded outputs in the failure channel", async () => {
const requests: string[] = []
const httpClient = HttpClient.make((request) => {
requests.push(request.url)
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ output: "not a number" })))
})
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* Effect.flip(client.rpc(definition).count({ count: "1" }))
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(Schema.isSchemaError(error)).toBe(true)
expect(requests).toEqual(["http://localhost:3000/api/rpc/example/count"])
})
test("Effect RPC decodes declared errors and removes the generic transport wrapper", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "RpcError", type: "too_large", message: "Too large", data: { limit: "3" } },
{ status: 400 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.rpc(definition).count({ count: "4" }).pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual({ type: "too_large", message: "Too large", data: { limit: 3 } })
})
test("Effect RPC removes the internal transport wrapper", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" },
{ status: 500 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.rpc(definition).count({ count: "4" }).pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual({ type: "rpc.internal", message: "Failed" })
})
test("Effect RPC isolates per-call location and headers while preserving configured defaults and native behavior", async () => {
const requests: Array<{ url: URL; headers: HttpClientRequest.HttpClientRequest["headers"] }> = []
const release = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
const httpClient = HttpClient.make((request, url) => {
requests.push({ url, headers: request.headers })
if (requests.length === 1) started.resolve()
return Effect.promise(() => release.promise).pipe(
Effect.as(
HttpClientResponse.fromWeb(
request,
url.pathname.endsWith("/health")
? Response.json({ healthy: true, version: "test", pid: 1 })
: Response.json({ output: "3" }),
),
),
)
}).pipe(HttpClient.mapRequest(HttpClientRequest.setHeaders({ authorization: "Bearer base", "x-default": "base" })))
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
)
const rpc = client.rpc(definition)
const first = Effect.runPromise(
rpc.count(
{ count: "1" },
{ location: { directory: "/project/one", workspace: "one" }, headers: { "x-call": "one" } },
),
)
await started.promise
const second = Effect.runPromise(
rpc.count(
{ count: "2" },
{ location: { directory: "/project/two" }, headers: new Headers({ "x-call": "two", "x-default": "override" }) },
),
)
const native = Effect.runPromise(client.health.get())
release.resolve()
expect(await Promise.all([first, second])).toEqual([3, 3])
expect(await native).toEqual({ healthy: true, version: "test", pid: 1 })
expect(requests.map((request) => request.headers.authorization)).toEqual([
"Bearer base",
"Bearer base",
"Bearer base",
])
expect(requests.map((request) => request.headers["x-call"])).toEqual(["one", "two", undefined])
expect(requests.map((request) => request.headers["x-default"])).toEqual(["base", "override", "base"])
expect(requests.map((request) => request.url.searchParams.get("location[directory]"))).toEqual([
"/project/one",
"/project/two",
null,
])
expect(requests.map((request) => request.url.searchParams.get("location[workspace]"))).toEqual(["one", null, null])
})
test("RPC signals and consumer interruption abort only their own HTTP calls", async () => {
const started: Array<ReturnType<typeof Promise.withResolvers<AbortSignal>>> = [
Promise.withResolvers<AbortSignal>(),
Promise.withResolvers<AbortSignal>(),
]
const signals: AbortSignal[] = []
const finalized: number[] = []
const httpClient = HttpClient.make((_request, _url, signal) => {
const index = signals.length
signals.push(signal)
started[index].resolve(signal)
return Effect.never.pipe(Effect.ensuring(Effect.sync(() => finalized.push(index))))
})
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
)
const rpc = client.rpc(definition)
const abort = new AbortController()
const first = Effect.runFork(rpc.count({ count: "1" }, { signal: abort.signal }))
const second = Effect.runFork(rpc.count({ count: "2" }))
await Promise.all(started.map((entry) => entry.promise))
abort.abort()
const exit = await Effect.runPromise(Fiber.await(first))
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(signals.map((signal) => signal.aborted)).toEqual([true, false])
expect(finalized).toEqual([0])
await Effect.runPromise(Fiber.interrupt(second))
expect(signals[1].aborted).toBe(true)
expect(finalized).toEqual([0, 1])
const preAborted = await Effect.runPromiseExit(rpc.count({ count: "3" }, { signal: abort.signal }))
expect(Exit.isFailure(preAborted) && Cause.hasInterruptsOnly(preAborted.cause)).toBe(true)
expect(signals).toHaveLength(2)
})
test("native and RPC Effect streams share one lazy source, cache connected, and filter across all locations", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const rpc = client.rpc(definition)
const native = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
const progress = Stream.toAsyncIterable(rpc.events.subscribe("progress"))[Symbol.asyncIterator]()
expect(source.requests).toHaveLength(0)
const marker = native.next()
await source.push(connected)
expect((await marker).value).toEqual(connected)
const first = progress.next()
const late = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
expect((await late.next()).value).toEqual(connected)
await native.return?.()
await late.return?.()
await source.push(rpcEvent("ignored", "/project/one", "other"))
await source.push(rpcEvent("ignored", "/project/one", "example", "message"))
await source.push(rpcEvent("1"))
expect((await first).value).toEqual({
id: "evt_progress",
created: 123,
type: "rpc.example.progress",
metadata: { origin: "test" },
data: { count: 1 },
location: { directory: "/project/one" },
})
const second = progress.next()
await source.push(rpcEvent("2", "/project/two"))
expect((await second).value).toEqual(
expect.objectContaining({ data: { count: 2 }, location: { directory: "/project/two" } }),
)
expect(source.requests).toHaveLength(1)
expect((await source.opened).signal.aborted).toBe(false)
const third = progress.next()
await source.push(rpcEvent("3"))
expect((await third).value.data).toEqual({ count: 3 })
const pending = progress.next()
await progress.return?.()
expect((await pending).done).toBe(true)
await source.cancelled
expect((await source.opened).signal.aborted).toBe(true)
})
test("interrupting a native Effect stream leaves an active RPC consumer running", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runFork(Stream.runCollect(client.event.subscribe()))
const progress = Stream.toAsyncIterable(client.rpc(definition).events.subscribe("progress"))[Symbol.asyncIterator]()
const first = progress.next()
await source.push(rpcEvent("1"))
expect((await first).value.data).toEqual({ count: 1 })
await Effect.runPromise(Fiber.interrupt(native))
expect((await source.opened).signal.aborted).toBe(false)
const second = progress.next()
await source.push(rpcEvent("2"))
expect((await second).value.data).toEqual({ count: 2 })
await progress.return?.()
await source.cancelled
})
test("shared Effect streams preserve EOF without reconnecting", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Stream.runCollect(client.event.subscribe()))
const progress = Effect.runPromise(Stream.runCollect(client.rpc(definition).events.subscribe("progress")))
await source.push(connected)
await source.push(rpcEvent("1"))
const connection = await source.opened
connection.controller.close()
expect((await native).map((event) => event.type)).toEqual(["server.connected", "rpc.example.progress"])
expect((await progress).map((event) => event.data)).toEqual([{ count: 1 }])
expect(source.requests).toHaveLength(1)
})
test("native protocol failures reach both native and RPC streams as ClientError", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Effect.flip(Stream.runCollect(client.event.subscribe())))
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push({ type: "server.connected" })
expect((await native)._tag).toBe("ClientError")
expect(await progress).toBe(await native)
expect(source.requests).toHaveLength(1)
})
test("HTTP source failures reach every Effect consumer", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Effect.flip(Stream.runCollect(client.event.subscribe())))
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push(connected)
const connection = await source.opened
connection.controller.error(new Error("connection lost"))
expect((await native)._tag).toBe("ClientError")
expect(await progress).toBe(await native)
expect(source.requests).toHaveLength(1)
})
test("RPC payload decoding fails only the matching consumer, not the native event stream", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
const raw = native.next()
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push(rpcEvent("not a number"))
expect((await raw).value.type).toBe("rpc.example.progress")
expect(Schema.isSchemaError(await progress)).toBe(true)
expect((await source.opened).signal.aborted).toBe(false)
const next = native.next()
await source.push(connected)
expect((await next).value.type).toBe("server.connected")
await native.return?.()
await source.cancelled
})
test("shared event source runs with the Effect context captured by make", async () => {
const Token = Context.Reference("test/rpc-effect/token", { defaultValue: () => "missing" })
const httpClient = HttpClient.make((request) =>
Effect.gen(function* () {
const token = yield* Token
expect(token).toBe("captured")
return HttpClientResponse.fromWeb(
request,
new Response(`data: ${JSON.stringify(connected)}\n\n`, { headers: { "content-type": "text/event-stream" } }),
)
}),
)
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.provideService(Token, "captured"),
),
)
expect((await Effect.runPromise(Stream.runCollect(client.event.subscribe())))[0]).toEqual(connected)
})
test("Effect RPC rejects inherited event names without opening the source", async () => {
const requests: string[] = []
const httpClient = HttpClient.make((request) => {
requests.push(request.url)
return Effect.die(new Error("Unexpected request"))
})
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const broad: Rpc.Definition = definition
return yield* client.rpc(broad).events.subscribe("toString").pipe(Stream.runDrain, Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual(new Error("Unknown RPC event: rpc.example.toString"))
expect(requests).toEqual([])
})
+361
View File
@@ -0,0 +1,361 @@
import { afterEach, expect, test } from "bun:test"
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { Rpc } from "@opencode-ai/schema/rpc"
import { z } from "zod"
import { OpenCode } from "../src/promise/index"
const cleanup = new Set<() => void>()
afterEach(() => {
cleanup.forEach((close) => close())
cleanup.clear()
})
const Echo = Rpc.define({
id: "acme/jobs",
methods: {
echo: {
input: z.string(),
output: z.string(),
errors: { rejected: z.object({ reason: z.string() }) },
},
raw: { input: z.unknown(), output: z.unknown() },
ping: { input: z.undefined(), output: z.undefined() },
},
events: {
updated: { schema: z.object({ count: z.number() }) },
},
})
const connected = { id: "evt_connected", created: 0, type: "server.connected", data: {} }
const rpcEvent = (data: unknown, directory = "/first", rpcID = Echo.id, name = "updated") => ({
id: "evt_rpc",
created: 10,
type: `rpc.${rpcID}.${name}`,
location: { directory },
metadata: { source: "test" },
data,
})
function http(fetch: (request: Request) => Response | Promise<Response>) {
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch })
cleanup.add(() => server.stop(true))
return OpenCode.make({ baseUrl: server.url.href, headers: { authorization: "Bearer default", "x-base": "base" } })
}
function events() {
const requests: Request[] = []
const opened = Promise.withResolvers<ReadableStreamDefaultController<Uint8Array>>()
const cancelled = Promise.withResolvers<void>()
const encoder = new TextEncoder()
let stopped = false
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
headers: { authorization: "Bearer events" },
fetch: async (input, init) => {
const request = new Request(input, init)
requests.push(request)
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const abort = () => {
if (stopped) return
stopped = true
controller.error(request.signal.reason)
cancelled.resolve()
}
request.signal.addEventListener("abort", abort, { once: true })
cleanup.add(abort)
opened.resolve(controller)
controller.enqueue(encoder.encode(`data: ${JSON.stringify(connected)}\n\n`))
},
cancel() {
stopped = true
cancelled.resolve()
},
})
return new Response(stream, { headers: { "content-type": "text/event-stream" } })
},
})
return {
client,
requests,
cancelled: cancelled.promise,
async send(value: unknown) {
return (await opened.promise).enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`))
},
async end() {
stopped = true
return (await opened.promise).close()
},
async fail(error: Error) {
stopped = true
return (await opened.promise).error(error)
},
}
}
test("rpc is callable, retains raw call, and routes method location, headers, and JSON body", async () => {
const requests: Array<{ url: string; method: string; headers: Headers; body: unknown }> = []
const client = http(async (request) => {
const body = await request.json()
requests.push({ url: request.url, method: request.method, headers: request.headers, body })
return Response.json({ output: body.input })
})
expect(typeof client.rpc).toBe("function")
expect(typeof client.rpc.call).toBe("function")
expect(
await client.rpc(Echo).echo("hello", {
location: { directory: "/project with spaces", workspace: "wrk_test" },
headers: { authorization: "Bearer override", "x-call": "call" },
}),
).toBe("hello")
const url = new URL(requests[0].url)
expect(url.pathname).toBe("/api/rpc/acme%2Fjobs/echo")
expect(url.searchParams.get("location[directory]")).toBe("/project with spaces")
expect(url.searchParams.get("location[workspace]")).toBe("wrk_test")
expect(requests[0].body).toEqual({ input: "hello" })
expect(requests[0].method).toBe("POST")
expect(requests[0].headers.get("authorization")).toBe("Bearer override")
expect(requests[0].headers.get("x-base")).toBe("base")
expect(requests[0].headers.get("x-call")).toBe("call")
expect(await client.rpc.call({ rpcID: Echo.id, method: "echo", input: "raw" })).toEqual({ output: "raw" })
expect(new URL(requests[1].url).search).toBe("")
expect(requests[1].headers.get("authorization")).toBe("Bearer default")
})
test("no-input RPC methods and absent output use empty wrappers", async () => {
const client = http(async (request) => {
expect(await request.json()).toEqual({})
return Response.json({})
})
expect(await client.rpc(Echo).ping()).toBeUndefined()
expect(await client.rpc(Echo).ping(undefined, { location: { directory: "/project" } })).toBeUndefined()
})
test("RPC Standard Schema results are already parsed and are not transformed again", async () => {
const calls = { input: 0, output: 0 }
const input: StandardSchemaV1<string, number> = {
"~standard": {
version: 1,
vendor: "test",
validate: (value) => {
calls.input++
return { value: Number(value) }
},
},
}
const output: StandardSchemaV1<number, string> = {
"~standard": {
version: 1,
vendor: "test",
validate: (value) => {
calls.output++
return { value: String(value) }
},
},
}
const eventOutput: StandardSchemaV1<{ count: number }, { text: string }> = {
"~standard": {
version: 1,
vendor: "test",
validate: (value) => {
if (typeof value !== "object" || value === null || !("count" in value) || typeof value.count !== "number")
return { issues: [{ message: "Expected count" }] }
return { value: { text: String(value.count) } }
},
},
}
const definition = Rpc.define({
id: "standard",
methods: { count: { input, output } },
events: { counted: { schema: eventOutput } },
})
const client = http(async (request) => {
expect(await request.json()).toEqual({ input: "41" })
return Response.json({ output: "42" })
})
expect(await client.rpc(definition).count("41")).toBe("42")
const source = events()
const iterator = source.client.rpc(definition).events.subscribe("counted")[Symbol.asyncIterator]()
const next = iterator.next()
await source.send(rpcEvent({ text: "42" }, "/project", definition.id, "counted"))
expect((await next).value?.data).toEqual({ text: "42" })
await iterator.return?.()
expect(calls).toEqual({ input: 0, output: 0 })
})
test("RPC method signals cancel an in-flight HTTP request", async () => {
const received = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const client = http(() => {
received.resolve()
return response.promise
})
const controller = new AbortController()
const result = client
.rpc(Echo)
.echo("hello", { signal: controller.signal })
.catch((error: unknown) => error)
await received.promise
controller.abort()
expect(await result).toMatchObject({ name: "ClientError", reason: "Transport" })
response.resolve(Response.json({ output: "late" }))
})
test("RPC pre-aborted methods do not issue HTTP requests", async () => {
let requests = 0
const client = http(() => {
requests++
return Response.json({ output: "hello" })
})
await expect(client.rpc(Echo).echo("hello", { signal: AbortSignal.abort() })).rejects.toBeDefined()
expect(requests).toBe(0)
})
test("RPC declared HTTP failures propagate", async () => {
await expect(
http(() => Response.json({ _tag: "UnauthorizedError", message: "Denied" }, { status: 401 }))
.rpc(Echo)
.echo("hello"),
).rejects.toMatchObject({ _tag: "UnauthorizedError", message: "Denied" })
})
test("RPC method failures remove the generic transport wrapper", async () => {
const response = { _tag: "RpcError", type: "rejected", message: "Rejected", data: { reason: "busy" } }
const client = http(() => Response.json(response, { status: 400 }))
const error = await client.rpc(Echo).echo("hello").catch((error: unknown) => error)
expect(error).toEqual({ type: "rejected", message: "Rejected", data: { reason: "busy" } })
await expect(client.rpc.call({ rpcID: Echo.id, method: "echo", input: "hello" })).rejects.toEqual(response)
})
test("RPC transport failures remove the generic transport wrapper", async () => {
const response = { _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" }
await expect(http(() => Response.json(response, { status: 500 })).rpc(Echo).echo("hello")).rejects.toEqual({
type: "rpc.internal",
message: "Failed",
})
})
test("native events and multiple RPC clients share one lazy source across locations", async () => {
const source = events()
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
const first = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
const second = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
const otherDefinition = Rpc.define({ ...Echo, id: "other" })
const other = source.client.rpc(otherDefinition).events.subscribe("updated")[Symbol.asyncIterator]()
expect(source.requests).toHaveLength(0)
const firstNext = first.next()
const secondNext = second.next()
const otherNext = other.next()
expect(await native.next()).toEqual({ done: false, value: connected })
expect(source.requests).toHaveLength(1)
expect(source.requests[0].headers.get("authorization")).toBe("Bearer events")
const late = source.client.event.subscribe()[Symbol.asyncIterator]()
expect(await late.next()).toEqual({ done: false, value: connected })
await Promise.all([native.return?.(), late.return?.()])
await source.send(rpcEvent({ ignored: true }, "/first", Echo.id, "unknown"))
await source.send(rpcEvent({ count: 9 }, "/other", otherDefinition.id))
expect((await otherNext).value).toMatchObject({
type: "rpc.other.updated",
location: { directory: "/other" },
data: { count: 9 },
})
await other.return?.()
await source.send(rpcEvent({ count: 42 }))
const expected = {
id: "evt_rpc",
created: 10,
type: `rpc.${Echo.id}.updated`,
location: { directory: "/first" },
metadata: { source: "test" },
data: { count: 42 },
}
expect(await firstNext).toEqual({ done: false, value: expected })
expect(await secondNext).toEqual({ done: false, value: expected })
const next = first.next()
await source.send(rpcEvent({ count: 43 }, "/second"))
expect((await next).value).toMatchObject({ location: { directory: "/second" }, data: { count: 43 } })
await Promise.all([first.return?.(), second.return?.()])
await source.cancelled
expect(source.requests[0].signal.aborted).toBe(true)
expect(source.requests).toHaveLength(1)
})
test("RPC iterator return and abort cancel only their pending subscribers", async () => {
const source = events()
const controller = new AbortController()
const first = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
const secondEvents = source.client.rpc(Echo).events.subscribe("updated", { signal: controller.signal })
const second = secondEvents[Symbol.asyncIterator]()
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
const firstNext = first.next()
const secondNext = second.next()
await native.next()
expect((await first.return?.())?.done).toBe(true)
expect((await firstNext).done).toBe(true)
expect(source.requests[0].signal.aborted).toBe(false)
controller.abort()
expect((await secondNext).done).toBe(true)
expect(source.requests[0].signal.aborted).toBe(false)
const nativeNext = native.next()
const event = rpcEvent({ count: 42 })
await source.send(event)
expect(await nativeNext).toEqual({ done: false, value: event })
await native.return?.()
await source.cancelled
})
test("RPC callback subscriptions unsubscribe independently", async () => {
const source = events()
const received = Promise.withResolvers<unknown>()
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
await native.next()
const unsubscribe = source.client.rpc(Echo).events.on("updated", received.resolve)
await source.send(rpcEvent({ count: 42 }))
expect(await received.promise).toMatchObject({ data: { count: 42 }, type: `rpc.${Echo.id}.updated` })
unsubscribe()
unsubscribe()
expect(source.requests[0].signal.aborted).toBe(false)
await native.return?.()
await source.cancelled
})
test("RPC async callback failures stop only that listener and are not unhandled", async () => {
const source = events()
const client = source.client.rpc(Echo)
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const failed: number[] = []
cleanup.add(release.resolve)
cleanup.add(
client.events.on("updated", async (event) => {
failed.push(event.data.count)
started.resolve()
await release.promise
throw new Error("Expected async RPC callback failure")
}),
)
const healthy = client.events.subscribe("updated")[Symbol.asyncIterator]()
const first = healthy.next()
await source.send(rpcEvent({ count: 1 }))
await started.promise
expect((await first).value.data.count).toBe(1)
const second = healthy.next()
await source.send(rpcEvent({ count: 2 }))
expect((await second).value.data.count).toBe(2)
expect(failed).toEqual([1])
release.resolve()
await healthy.return?.()
await source.cancelled
expect(failed).toEqual([1])
})
test("RPC checks unknown event names and pre-aborted subscriptions remain lazy", async () => {
const source = events()
const broad: Rpc.PortableDefinition = Echo
expect(() => source.client.rpc(broad).events.subscribe("unknown")).toThrow("Unknown RPC event")
expect(() => source.client.rpc(broad).events.subscribe("toString")).toThrow("Unknown RPC event")
expect(() => source.client.rpc(broad).events.on("unknown", () => {})).toThrow("Unknown RPC event")
const aborted = source.client.rpc(Echo).events.subscribe("updated", { signal: AbortSignal.abort() })
const iterator = aborted[Symbol.asyncIterator]()
expect((await iterator.next()).done).toBe(true)
expect(source.requests).toHaveLength(0)
})
@@ -8,7 +8,7 @@ import { Bus } from "../bus.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git.js"
import { Location } from "../location.js"
import { Plugin } from "../plugin.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { LocationWatcherPolicy } from "./location-watcher-policy.js"
import { Watcher } from "./watcher.js"
@@ -24,7 +24,7 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const plugins = yield* Plugin.Service
const plugins = yield* PluginSupervisor.Service
const policy = yield* LocationWatcherPolicy.Service
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
bus.publish(FileSystem.Event.Changed, {
@@ -109,5 +109,13 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Watcher.node, FSUtil.node, Location.node, Git.node, Bus.node, Plugin.node, LocationWatcherPolicy.node],
deps: [
Watcher.node,
FSUtil.node,
Location.node,
Git.node,
Bus.node,
PluginSupervisor.node,
LocationWatcherPolicy.node,
],
})
+4 -4
View File
@@ -46,8 +46,8 @@ import { InstructionBuiltIns } from "./instructions/builtins.js"
import { InstructionEntry } from "./session/instruction-entry.js"
import { SessionInstructions } from "./session/instructions.js"
import { SessionGenerateNode } from "./session/generate-node.js"
import { SessionPrompt } from "./session/prompt.js"
import { SessionRevert } from "./session/revert.js"
import { SessionPromptNode } from "./session/prompt-node.js"
import { SessionRevertNode } from "./session/revert-node.js"
import { McpTool } from "./tool/mcp.js"
import { ReadToolFileSystem } from "./tool/read-filesystem.js"
import { Tool } from "./tool.js"
@@ -97,8 +97,8 @@ const nodes = [
Form.node,
Generate.node,
SessionGenerateNode.node,
SessionPrompt.node,
SessionRevert.node,
SessionPromptNode.node,
SessionRevertNode.node,
ReadToolFileSystem.node,
McpTool.node,
SessionInstructions.node,
+110 -94
View File
@@ -4,20 +4,36 @@ export { Event, ID, Info, Source, State } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Cause, Context, Effect, Exit, Latch, Layer, Logger, References, Scope, Semaphore } from "effect"
import { App } from "./app.js"
import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js"
import { Catalog } from "./catalog.js"
import { Command } from "./command.js"
import { Bus } from "./bus.js"
import { Integration } from "./integration.js"
import { KV } from "./kv.js"
import { Mcp } from "./mcp/index.js"
import { Location } from "./location.js"
import { PluginHost } from "./plugin/host.js"
import { PluginRuntime } from "./plugin/runtime.js"
import { WebSearch } from "./websearch.js"
import { Reference } from "./reference.js"
import { Rpc } from "./rpc.js"
import { Skill } from "./skill.js"
import { State } from "./state.js"
import { Tool } from "./tool.js"
import { Vcs } from "./vcs.js"
import { PluginHooks } from "./plugin/hooks.js"
import { Generate } from "./generate.js"
import { Permission } from "./permission.js"
export interface Interface {
readonly activate: (plugins: readonly Generation[], failures?: readonly Failure[]) => Effect.Effect<void>
readonly activate: (
plugins: readonly Generation[],
failures?: readonly Failure[],
) => Effect.Effect<void>
readonly list: () => Effect.Effect<Plugin.Info[]>
readonly close: (exit: Exit.Exit<unknown, unknown>) => Effect.Effect<void>
/** Wait for announced updates and activation to settle; failures remain in the inventory. */
readonly awaitActivation: Effect.Effect<void>
/** Keep readiness pending while preparing an update. Run the returned Effect to release it. */
readonly hold: () => Effect.Effect<Effect.Effect<void>>
}
type Failure = Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
@@ -38,17 +54,6 @@ const layer = Layer.effect(
const scope = yield* Scope.make()
const active = new Map<Plugin.ID, { readonly plugin: Generation; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1)
const ready = yield* Latch.make(true)
const pending = new Set<object>()
const hold = () =>
Effect.sync(() => {
const token = {}
pending.add(token)
ready.closeUnsafe()
return Effect.sync(() => {
if (pending.delete(token) && pending.size === 0) ready.openUnsafe()
})
})
let inventory: Plugin.Info[] = []
let host: Parameters<PluginDefinition["effect"]>[0]
const load = Effect.fnUntraced(function* (plugin: Generation) {
@@ -88,92 +93,82 @@ const layer = Layer.effect(
ids.add(definition.id)
}
yield* Effect.acquireUseRelease(
hold(),
() =>
lock.withPermit(
yield* lock.withPermit(
Effect.gen(function* () {
if (
active.size === definitions.length &&
Array.from(active.values()).every((entry, index) => {
const definition = definitions[index]
return entry.plugin.id === definition?.id && entry.plugin.revision === definition.revision
})
) {
for (const definition of definitions) {
const entry = active.get(definition.id)
if (entry) active.set(definition.id, { ...entry, plugin: definition })
}
const nextInventory = [...definitions.map(activeInfo), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
return
}
yield* State.batch(
Effect.gen(function* () {
if (
active.size === definitions.length &&
Array.from(active.values()).every((entry, index) => {
const definition = definitions[index]
return entry.plugin.id === definition?.id && entry.plugin.revision === definition.revision
})
) {
for (const definition of definitions) {
const entry = active.get(definition.id)
if (entry) active.set(definition.id, { ...entry, plugin: definition })
const nextInventory: Plugin.Info[] = []
for (const definition of definitions) {
const previous = active.get(definition.id)
active.delete(definition.id)
if (previous) yield* Scope.close(previous.scope, Exit.void)
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
}
const nextInventory = [...definitions.map(activeInfo), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
return
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
state: { status: "failed", error: loaded.error },
features: { server: true, ...definition.features },
})
if (!previous) continue
const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
"plugin.id": definition.id,
})
}
yield* State.batch(
Effect.gen(function* () {
const nextInventory: Plugin.Info[] = []
for (const definition of definitions) {
const previous = active.get(definition.id)
active.delete(definition.id)
if (previous) yield* Scope.close(previous.scope, Exit.void)
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
}
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
state: { status: "failed", error: loaded.error },
features: { server: true, ...definition.features },
})
if (!previous) continue
const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
"plugin.id": definition.id,
})
}
const removed = Array.from(active.entries())
.filter(([id]) => !ids.has(id))
.toReversed()
removed.forEach(([id]) => active.delete(id))
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void), {
discard: true,
})
inventory = [...nextInventory, ...failures]
}),
)
yield* bus.publish(Plugin.Event.Updated, {})
const removed = Array.from(active.entries())
.filter(([id]) => !ids.has(id))
.toReversed()
removed.forEach(([id]) => active.delete(id))
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void), {
discard: true,
})
inventory = [...nextInventory, ...failures]
}),
),
(release) => release,
)
yield* bus.publish(Plugin.Event.Updated, {})
}),
)
})
const close = (exit: Exit.Exit<unknown, unknown>) =>
lock.withPermit(
Effect.gen(function* () {
active.clear()
yield* State.batch(Scope.close(scope, exit), { flush: false })
}),
)
yield* Effect.addFinalizer(close)
yield* Effect.addFinalizer((exit) =>
Effect.gen(function* () {
active.clear()
yield* State.batch(Scope.close(scope, exit), { flush: false })
}),
)
const service = Service.of({
activate,
close,
awaitActivation: ready.await,
hold,
list: Effect.fn("Plugin.list")(function* () {
return inventory
}),
@@ -195,5 +190,26 @@ function activeInfo(plugin: Generation): Plugin.Info {
export const node = makeLocationNode({
service: Service,
layer,
deps: [PluginHost.requirements],
deps: [
Bus.node,
App.node,
Agent.node,
AISDK.node,
Catalog.node,
Command.node,
Integration.node,
KV.node,
Mcp.node,
Location.node,
Reference.node,
Rpc.node,
Skill.node,
Tool.node,
Vcs.node,
PluginHooks.node,
PluginRuntime.node,
WebSearch.node,
Generate.node,
Permission.node,
],
})
+4 -55
View File
@@ -5,7 +5,6 @@ import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/i
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import type { Event } from "@opencode-ai/schema/event"
import { ServerConfig } from "@opencode-ai/schema/mcp"
import type { Session } from "@opencode-ai/schema/session"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
@@ -32,9 +31,7 @@ import { WebSearch } from "../websearch.js"
import { Generate } from "../generate.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "./hooks.js"
import { Service, type Interface } from "../plugin.js"
import { SessionGenerate } from "../session/generate.js"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import type { Interface } from "../plugin.js"
const mutable = <T>(value: T) => value as DeepMutable<T>
type RpcEvent = Event.Payload & {
@@ -43,10 +40,7 @@ type RpcEvent = Event.Payload & {
readonly data: Readonly<Record<string, unknown>>
}
const isRpcEvent = (event: Event.Payload): event is RpcEvent => event.type.startsWith("rpc.")
export const make = Effect.fn("PluginHost.make")(function* (
plugin: Pick<Interface, "list">,
pluginID: string = "test",
) {
export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, pluginID: string = "test") {
const app = yield* App.Metadata
const agents = yield* Agent.Service
const aisdk = yield* AISDK.Service
@@ -67,8 +61,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
const permission = yield* Permission.Service
const hooks = yield* PluginHooks.Service
const runtime = yield* PluginRuntime.Service
const route = <A, E, R>(sessionID: Session.ID, effect: Effect.Effect<A, E, R>) =>
runtime.session.get(sessionID).pipe(Effect.flatMap((session) => runtime.instances.provide(session)(effect)))
const locationInfo = () =>
new Location.Info({
directory: location.directory,
@@ -447,28 +439,8 @@ export const make = Effect.fn("PluginHost.make")(function* (
switchAgent: runtime.session.switchAgent,
switchModel: runtime.session.switchModel,
prompt: runtime.session.prompt,
generate: (input) =>
route(
input.sessionID,
SessionGenerate.Service.use((generate) => generate.generate(input)),
).pipe(Effect.map((text) => ({ text }))),
command: (input) =>
route(
input.sessionID,
Effect.gen(function* () {
const plugins = yield* Service
yield* plugins.awaitActivation
const commands = yield* Command.Service
yield* commands.execute({
name: input.command,
invocation: {
sessionID: input.sessionID,
prompt: { text: input.text, files: input.files, agents: input.agents, skills: input.skills },
delivery: input.delivery ?? "steer",
},
})
}),
),
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
command: runtime.session.command,
rename: runtime.session.rename,
move: runtime.session.move,
synthetic: runtime.session.synthetic,
@@ -483,29 +455,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
return context
})
export const requirements = LayerNode.group([
App.node,
Agent.node,
AISDK.node,
Catalog.node,
Command.node,
Bus.node,
Integration.node,
KV.node,
Mcp.node,
Location.node,
Reference.node,
Rpc.node,
Skill.node,
Tool.node,
Vcs.node,
WebSearch.node,
Generate.node,
Permission.node,
PluginHooks.node,
PluginRuntime.node,
])
export function storage(kv: KV.Interface, pluginID: string): Plugin.Context["storage"] {
const namespace = `plugin:${pluginID
.split("")
+3 -1
View File
@@ -40,6 +40,8 @@ export function bound(plugins: List) {
if (duplicates.length > 0) {
throw new Error(`duplicate instance plugin ids: ${duplicates.map((plugin) => plugin.id).join(", ")}`)
}
const stamped = plugins.map((plugin): Generation => ({ ...plugin, revision: "instance", source: { type: "sdk" } }))
const stamped = plugins.map(
(plugin): Generation => ({ ...plugin, revision: "instance", source: { type: "sdk" } }),
)
return Layer.succeed(Service, Service.of({ all: () => stamped }))
}
+92 -47
View File
@@ -89,52 +89,98 @@ import { VcsGitPlugin } from "./vcs/git.js"
import { WarmingPlugin } from "./warming.js"
import { WellKnownPlugin } from "../wellknown/plugin.js"
const services = [
Agent.Service,
AppProcess.Service,
Catalog.Service,
Command.Service,
Config.Service,
Credential.Service,
Bus.Service,
Environment.Service,
FileMutation.Service,
Formatter.Service,
LocationWatcherPolicy.Service,
FileSystem.Service,
FSUtil.Service,
Global.Service,
HttpClient.HttpClient,
Image.Service,
InstructionDiscovery.Service,
Integration.Service,
KV.Service,
Location.Service,
LocationMutation.Service,
ModelsDev.Service,
Mcp.Service,
Npm.Service,
Permission.Service,
PluginRuntime.Service,
Form.Service,
ReadToolFileSystem.Service,
Reference.Service,
WebSearch.Service,
Ripgrep.Service,
SessionCompaction.Service,
SessionInstructions.Service,
Shell.Service,
ShellSelect.Service,
Snapshot.Service,
Skill.Service,
SkillDiscovery.Service,
Tool.Service,
ToolOutput.Service,
Watcher.Service,
WellKnown.Service,
] as const
const services = Effect.fn("PluginInternal.services")(function* () {
const agent = yield* Agent.Service
const processes = yield* AppProcess.Service
const catalog = yield* Catalog.Service
const command = yield* Command.Service
const config = yield* Config.Service
const credential = yield* Credential.Service
const bus = yield* Bus.Service
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const locationWatcherPolicy = yield* LocationWatcherPolicy.Service
const filesystem = yield* FileSystem.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const http = yield* HttpClient.HttpClient
const image = yield* Image.Service
const instructionDiscovery = yield* InstructionDiscovery.Service
const integration = yield* Integration.Service
const kv = yield* KV.Service
const location = yield* Location.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
const mcp = yield* Mcp.Service
const npm = yield* Npm.Service
const permission = yield* Permission.Service
const runtime = yield* PluginRuntime.Service
const form = yield* Form.Service
const read = yield* ReadToolFileSystem.Service
const reference = yield* Reference.Service
const websearch = yield* WebSearch.Service
const ripgrep = yield* Ripgrep.Service
const compaction = yield* SessionCompaction.Service
const instructions = yield* SessionInstructions.Service
const shell = yield* Shell.Service
const shellSelect = yield* ShellSelect.Service
const snapshot = yield* Snapshot.Service
const skill = yield* Skill.Service
const skillDiscovery = yield* SkillDiscovery.Service
const tools = yield* Tool.Service
const toolOutput = yield* ToolOutput.Service
const watcher = yield* Watcher.Service
const wellknown = yield* WellKnown.Service
return Context.mergeAll(
Context.make(Agent.Service, agent),
Context.make(AppProcess.Service, processes),
Context.make(Catalog.Service, catalog),
Context.make(Command.Service, command),
Context.make(Config.Service, config),
Context.make(Credential.Service, credential),
Context.make(Bus.Service, bus),
Context.make(Environment.Service, environment),
Context.make(FileMutation.Service, mutation),
Context.make(Formatter.Service, formatter),
Context.make(LocationWatcherPolicy.Service, locationWatcherPolicy),
Context.make(FileSystem.Service, filesystem),
Context.make(FSUtil.Service, fs),
Context.make(Global.Service, global),
Context.make(HttpClient.HttpClient, http),
Context.make(Image.Service, image),
Context.make(InstructionDiscovery.Service, instructionDiscovery),
Context.make(Integration.Service, integration),
Context.make(KV.Service, kv),
Context.make(Location.Service, location),
Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models),
Context.make(Mcp.Service, mcp),
Context.make(Npm.Service, npm),
Context.make(Permission.Service, permission),
Context.make(PluginRuntime.Service, runtime),
Context.make(Form.Service, form),
Context.make(ReadToolFileSystem.Service, read),
Context.make(Reference.Service, reference),
Context.make(WebSearch.Service, websearch),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionCompaction.Service, compaction),
Context.make(SessionInstructions.Service, instructions),
Context.make(Shell.Service, shell),
Context.make(ShellSelect.Service, shellSelect),
Context.make(Snapshot.Service, snapshot),
Context.make(Skill.Service, skill),
Context.make(SkillDiscovery.Service, skillDiscovery),
Context.make(Tool.Service, tools),
Context.make(ToolOutput.Service, toolOutput),
Context.make(Watcher.Service, watcher),
Context.make(WellKnown.Service, wellknown),
)
})
export type Requirements = Context.Service.Identifier<(typeof services)[number]>
type ContextServices<A> = A extends Context.Context<infer R> ? R : never
export type Requirements = ContextServices<Effect.Success<ReturnType<typeof services>>>
export const requirements = LayerNode.group([
Agent.node,
@@ -232,8 +278,7 @@ const post = [
] as const satisfies readonly InternalPlugin[]
export const list = Effect.fn("PluginInternal.list")(function* () {
// Capture only services; activation supplies the child Scope and batching context.
const context = Context.pick(...services)(yield* Effect.context<Requirements>())
const context = yield* services()
const resolve = (plugins: readonly InternalPlugin[]) =>
plugins.map(
(plugin): Plugin => ({
+2 -2
View File
@@ -11,7 +11,7 @@ import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Generation } from "../plugin.js"
import { PluginPromise } from "./promise.js"
const Module = Schema.Struct({
const Definition = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
@@ -50,7 +50,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(Module)(mod).pipe(
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod).pipe(
Effect.mapError(
(cause) =>
new LoadError({
+3 -118
View File
@@ -1,6 +1,3 @@
import { readFile } from "node:fs/promises"
import { homedir } from "node:os"
import { join } from "node:path"
import { Clock, Effect, Schema, Semaphore, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { define } from "@opencode-ai/plugin/effect/plugin"
@@ -20,9 +17,6 @@ const cognitiveScope = "https://cognitiveservices.azure.com/.default"
const foundryScope = "https://ai.azure.com/.default"
const methodID = Integration.MethodID.make("azure-cli")
const decodeJSON = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
const decodeProfile = Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.Struct({ subscriptions: Schema.Array(Schema.Unknown) })),
)
const decodeToken = Schema.decodeUnknownEffect(
Schema.Struct({
accessToken: Schema.NonEmptyString,
@@ -30,20 +24,6 @@ const decodeToken = Schema.decodeUnknownEffect(
expiresOn: Schema.optional(Schema.NonEmptyString),
}),
)
const decodeAccounts = Schema.decodeUnknownEffect(
Schema.Array(Schema.Struct({ name: Schema.NonEmptyString, resourceGroup: Schema.NonEmptyString })),
)
const Deployments = Schema.Array(
Schema.Struct({
name: Schema.NonEmptyString,
properties: Schema.Struct({
model: Schema.Struct({ name: Schema.NonEmptyString }),
provisioningState: Schema.NonEmptyString,
}),
}),
)
const decodeDeployments = Schema.decodeUnknownEffect(Deployments)
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
if (useChat && sdk.chat) return sdk.chat(modelID)
if (sdk.responses) return sdk.responses(modelID)
@@ -60,7 +40,7 @@ export const AzurePlugin = define({
const bus = yield* Bus.Service
const tokens = new Map<string, { access: string; expires: number }>()
const loading = Semaphore.makeUnsafe(1)
const loaded: { resource?: string; deployments?: typeof Deployments.Type } = {}
const loaded: { resource?: string } = {}
const command = (args: string[]) =>
processes
@@ -86,28 +66,7 @@ export const AzurePlugin = define({
})
const available = Boolean(which("az"))
// Installing Azure CLI does not mean the user has signed in. Avoid spawning it for unrelated CLI commands.
const signedIn = available
? yield* Effect.tryPromise(() =>
readFile(join(process.env.AZURE_CONFIG_DIR ?? join(homedir(), ".azure"), "azureProfile.json"), "utf8"),
).pipe(
Effect.flatMap((text) => decodeProfile(text.replace(/^\uFEFF/, ""))),
Effect.map((profile) => profile.subscriptions.length > 0),
Effect.orElseSucceed(() => false),
)
: false
const accounts =
!resolveResourceName(configured) &&
typeof configured?.baseURL !== "string" &&
!process.env.AZURE_RESOURCE_GROUP &&
signedIn
? yield* command(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"]).pipe(
Effect.flatMap(decodeAccounts),
Effect.orElseSucceed(() => []),
)
: []
const form = (select = false) =>
const form = () =>
iife(() => {
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
return Form.Fields.make([
@@ -117,16 +76,6 @@ export const AzurePlugin = define({
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: true,
...(select && accounts.length > 0
? {
options: accounts.map((account) => ({
value: account.name,
label: account.name,
description: account.resourceGroup,
})),
custom: true,
}
: {}),
},
])
})
@@ -143,7 +92,7 @@ export const AzurePlugin = define({
id: methodID,
type: "oauth",
label: "Microsoft Entra ID (Azure CLI)",
form: form(true),
form: form(),
},
authorize: (answer) =>
Effect.succeed({
@@ -156,7 +105,6 @@ export const AzurePlugin = define({
if (!resourceName) return yield* Effect.fail(new Error("Azure resource name is required"))
const current = yield* token(cognitiveScope)
loaded.resource = resourceName
loaded.deployments = yield* discover(resourceName)
yield* ctx.catalog.reload()
return Credential.OAuth.make({
type: "oauth",
@@ -177,42 +125,6 @@ export const AzurePlugin = define({
})
})
const discover = Effect.fn("AzurePlugin.discover")(function* (resource: string) {
return yield* Effect.gen(function* () {
const group = process.env.AZURE_RESOURCE_GROUP
const account = group
? { name: resource, resourceGroup: group }
: (accounts.length > 0
? accounts
: yield* command(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"]).pipe(
Effect.flatMap(decodeAccounts),
)
).find((item) => item.name.toLowerCase() === resource.toLowerCase())
if (!account)
return yield* Effect.fail(new Error(`Azure resource "${resource}" was not found in the active subscription`))
return yield* command([
"cognitiveservices",
"account",
"deployment",
"list",
"--name",
account.name,
"--resource-group",
account.resourceGroup,
"--output",
"json",
"--only-show-errors",
]).pipe(Effect.flatMap(decodeDeployments))
}).pipe(
Effect.catch((error) =>
Effect.logWarning("Azure model discovery failed", {
resource,
error: error instanceof Error ? error.message : String(error),
}).pipe(Effect.as(undefined)),
),
)
})
const load = Effect.fn("AzurePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
@@ -220,13 +132,11 @@ export const AzurePlugin = define({
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) {
loaded.resource = undefined
loaded.deployments = undefined
return
}
const resource =
typeof credential.metadata?.resourceName === "string" ? credential.metadata.resourceName : undefined
loaded.resource = resource
loaded.deployments = resource ? yield* discover(resource) : undefined
})
yield* load()
@@ -245,31 +155,6 @@ export const AzurePlugin = define({
: {}),
}
})
if (item.provider.id === Provider.ID.azure && loaded.deployments) {
// Startup batches catalog transforms, so match against the current draft rather than a pre-startup snapshot.
const existing = Array.from(item.models.values())
const found = new Map<Model.ID, Model.Info>()
loaded.deployments.forEach((deployment) => {
if (deployment.properties.provisioningState !== "Succeeded") return
const model = existing.find(
(model) => model.id.toLowerCase() === deployment.properties.model.name.toLowerCase(),
)
if (!model) return
const id = found.has(model.id) ? Model.ID.make(deployment.name) : model.id
found.set(id, {
...model,
id,
name: id === model.id ? model.name : `${model.name} (${deployment.name})`,
modelID: Model.ID.make(deployment.name),
})
})
for (const id of Array.from(item.models.keys())) {
if (!found.has(Model.ID.make(id))) evt.model.remove(item.provider.id, id)
}
for (const [id, model] of found) {
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
}
}
for (const model of item.models.values()) {
evt.model.update(item.provider.id, model.id, (draft) => {
if (resourceName && typeof draft.settings?.baseURL === "string")
@@ -1,82 +0,0 @@
export * as PluginRuntimeProvider from "./runtime-provider.js"
import { Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Agent } from "../agent.js"
import { Job } from "../job.js"
import { Instance } from "../instance/service.js"
import { Location } from "../location.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Mcp } from "../mcp/index.js"
import { PersistentPty } from "../persistent-pty.js"
import { Session } from "../session.js"
import { PluginRuntime } from "./runtime.js"
// Application wiring stays outside the facade so Plugin can be imported by Session.
export const configured = (cell: PluginRuntime.Cell) =>
makeGlobalNode({
name: "plugin-runtime-provider",
layer: Layer.effectDiscard(
Effect.gen(function* () {
const sessions = yield* Session.Service
const instances = yield* Instance.Service
const jobs = yield* Job.Service
const locations = yield* LocationServiceMap.Service
const persistentPty = yield* PersistentPty.Service
const runtime: PluginRuntime.Interface = {
session: sessions,
instances,
job: jobs,
persistentPty,
location: {
agent: {
list: (ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
const agents = yield* Agent.Service
return {
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: yield* agents.list(),
}
}).pipe(Effect.provide(locations.get(ref)), Effect.orDie),
},
mcp: {
list: (ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
const mcp = yield* Mcp.Service
return {
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: yield* mcp.servers(),
}
}).pipe(Effect.provide(locations.get(ref))),
},
},
}
cell.runtime = runtime
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (cell.runtime === runtime) cell.runtime = undefined
}),
)
}),
),
deps: [
PluginRuntime.node,
Session.node,
Instance.byLocationNode,
Job.node,
LocationServiceMap.node,
PersistentPty.node,
],
})
export const node = configured(PluginRuntime.defaultCell)
+76 -12
View File
@@ -1,14 +1,14 @@
export * as PluginRuntime from "./runtime.js"
import { Context, Effect, Layer } from "effect"
import type { Agent } from "../agent.js"
import type { Instance } from "../instance/service.js"
import { Agent } from "../agent.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import type { Job } from "../job.js"
import type { Location } from "../location.js"
import type { Mcp } from "../mcp/index.js"
import type { PersistentPty } from "../persistent-pty.js"
import type { Session } from "../session.js"
import { Job } from "../job.js"
import { Location } from "../location.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Mcp } from "../mcp/index.js"
import { PersistentPty } from "../persistent-pty.js"
import { Session } from "../session.js"
export interface Interface {
readonly session: Pick<
@@ -17,6 +17,8 @@ export interface Interface {
| "create"
| "messages"
| "prompt"
| "generate"
| "command"
| "rename"
| "move"
| "resume"
@@ -27,7 +29,6 @@ export interface Interface {
| "wait"
| "context"
>
readonly instances: Pick<Instance.Interface, "provide">
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel" | "completeBackground">
readonly persistentPty: Pick<PersistentPty.Interface, "read">
readonly location: {
@@ -59,7 +60,7 @@ const require = <A, E, R>(cell: Cell, f: (runtime: Interface) => Effect.Effect<A
return f(runtime)
})
export const defaultCell = makeCell()
const defaultCell = makeCell()
export const layerWithCell = (cell: Cell): Layer.Layer<Service> =>
Layer.succeed(
@@ -70,6 +71,8 @@ export const layerWithCell = (cell: Cell): Layer.Layer<Service> =>
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
rename: (input) => require(cell, (runtime) => runtime.session.rename(input)),
move: (input) => require(cell, (runtime) => runtime.session.move(input)),
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
@@ -80,9 +83,6 @@ export const layerWithCell = (cell: Cell): Layer.Layer<Service> =>
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
context: (sessionID) => require(cell, (runtime) => runtime.session.context(sessionID)),
},
instances: {
provide: (session) => (effect) => require(cell, (runtime) => runtime.instances.provide(session)(effect)),
},
job: {
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
wait: (input) => require(cell, (runtime) => runtime.job.wait(input)),
@@ -106,6 +106,70 @@ export const layerWithCell = (cell: Cell): Layer.Layer<Service> =>
}),
)
export const providerLayerWithCell = (cell: Cell) =>
Layer.effectDiscard(
Effect.gen(function* () {
const sessions = yield* Session.Service
const jobs = yield* Job.Service
const locations = yield* LocationServiceMap.Service
const persistentPty = yield* PersistentPty.Service
const runtime: Interface = {
session: sessions,
job: jobs,
persistentPty,
location: {
agent: {
list: (ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
const agents = yield* Agent.Service
return {
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: yield* agents.list(),
}
}).pipe(Effect.provide(locations.get(ref)), Effect.orDie),
},
mcp: {
list: (ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
const mcp = yield* Mcp.Service
return {
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: yield* mcp.servers(),
}
}).pipe(Effect.provide(locations.get(ref))),
},
},
}
cell.runtime = runtime
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (cell.runtime === runtime) cell.runtime = undefined
}),
)
}),
)
export const layer = layerWithCell(defaultCell)
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
// Raw layer replacements are compiled without dependencies, so cell-scoped
// provider replacements must go through this node to keep their deps wired.
export const providerNodeWithCell = (cell: Cell) =>
makeGlobalNode({
name: "plugin-runtime-provider",
layer: providerLayerWithCell(cell),
deps: [node, Session.node, Job.node, LocationServiceMap.node, PersistentPty.node],
})
export const providerNode = providerNodeWithCell(defaultCell)
+3 -2
View File
@@ -10,8 +10,9 @@ export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
/**
* Holds the plugins an embedder (the `@opencode-ai/sdk` host) contributes,
* so the application loader can add them on every Location boot through its
* ordinary generation path. Registration publishes an unlocated update so every booted Location
* so `PluginSupervisor` can add them on every Location boot through the ordinary
* generation path that `PluginSupervisor` uses for plugins discovered from
* config. Registration publishes an unlocated update so every booted Location
* reloads its plugin generation from the shared store.
*
* Each host-global layer owns one private store. Location graphs reuse that
@@ -0,0 +1,19 @@
export * as PluginSupervisor from "./supervisor-service.js"
import { Context, Effect } from "effect"
/**
* Dependency-only supervisor seam. Keep this module free of implementation
* imports: the supervisor reaches PluginRuntime, which depends on Session.
*/
export interface Interface {
/**
* Wait for configured plugin activation to settle, including missing-package installs.
* Completion does not imply every plugin succeeded.
* Interrupting this wait does not cancel activation. Use rarely: avoid blocking reads,
* UI startup, or unrelated work on plugin boot.
*/
readonly awaitActivation: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
+13 -24
View File
@@ -1,7 +1,8 @@
export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Layer, Stream } from "effect"
import { Cause, Effect, Latch, Layer, Stream } from "effect"
import path from "path"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -12,6 +13,7 @@ import { InstancePlugins } from "./instance.js"
import { PluginInternal } from "./internal.js"
import { PluginModule } from "./module.js"
import { SdkPlugins } from "./sdk.js"
import { Service } from "./supervisor-service.js"
import { PluginUpdate } from "./update.js"
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
@@ -91,7 +93,8 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
}
})
export const layer = Layer.effectDiscard(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const registry = yield* Plugin.Service
const sdk = yield* SdkPlugins.Service
@@ -99,11 +102,7 @@ export const layer = Layer.effectDiscard(
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const updates = yield* PluginUpdate.Service
const internal = yield* PluginInternal.list()
let release: Effect.Effect<void> | undefined = yield* registry.hold()
yield* Effect.addFinalizer(() => release ?? Effect.void)
// Built-ins capture services from this layer; unload them before those services close.
yield* Effect.addFinalizer(registry.close)
const ready = yield* Latch.make()
let packages = new Set<string>()
let outdated = new Set<string>()
let generation = 0
@@ -111,6 +110,8 @@ export const layer = Layer.effectDiscard(
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
const current = ++generation
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed plugins in boot order.
// Instance-bound plugins come last: later activation can override earlier
// container writes, so the instance's explicit choices win over globals.
@@ -172,7 +173,7 @@ export const layer = Layer.effectDiscard(
Stream.mapEffect(() =>
Effect.gen(function* () {
observed++
if (!release) release = yield* registry.hold()
yield* ready.close
return observed
}),
),
@@ -184,25 +185,13 @@ export const layer = Layer.effectDiscard(
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* activate().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
if (observed !== target) return
const settled = release
release = undefined
if (settled) yield* settled
if (observed === target) yield* ready.open
}),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("24 hours").pipe(
Effect.andThen(
Effect.acquireUseRelease(
registry.hold(),
() => activate(),
(release) => release,
),
),
Effect.forever,
Effect.forkScoped,
)
yield* Effect.sleep("24 hours").pipe(Effect.andThen(activate()), Effect.forever, Effect.forkScoped)
return Service.of({ awaitActivation: ready.await })
}),
)
@@ -222,4 +211,4 @@ function pluginSource(target: string): Plugin.Source {
return { type: "package", target }
}
export const node = makeLocationNode({ name: "plugin-supervisor", layer, deps: nodeDeps })
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
+45
View File
@@ -8,6 +8,7 @@ import { Project } from "./project.js"
import { Model } from "@opencode-ai/schema/model"
import { Location } from "./location.js"
import { SessionMessage } from "./session/message.js"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Bus } from "./bus.js"
import { Instance } from "./instance/service.js"
import { Database } from "./database/database.js"
@@ -44,11 +45,14 @@ import { LocationServiceMap } from "./location-service-map.js"
import { SessionEvent } from "./session/event.js"
import { SessionInbox } from "./session/inbox.js"
import { InstructionState } from "./session/instruction-state.js"
import { SessionGenerate } from "./session/generate.js"
import { Snapshot } from "./snapshot.js"
import { Session } from "./session/session.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { PluginSupervisor } from "./plugin/supervisor-service.js"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { Job } from "./job.js"
import { Command } from "./command.js"
import { Global } from "@opencode-ai/util/global"
import { SessionEnvironment } from "./session/environment.js"
import { InstructionEntry } from "./session/instruction-entry.js"
@@ -180,6 +184,20 @@ export interface Interface {
readonly prompt: (
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["prompt"]>
/** Generates text from current Session context without admitting input or mutating history. */
readonly generate: (input: {
sessionID: SessionSchema.ID
prompt: string
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
readonly command: (input: {
sessionID: SessionSchema.ID
command: string
text: string
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
readonly shell: (
input: Parameters<Session.Handle["shell"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["shell"]>
@@ -380,6 +398,33 @@ const layer = Layer.effect(
),
),
prompt: (input) => sessions.forSession(input.sessionID).prompt(input),
generate: Effect.fn("Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
const generate = yield* SessionGenerate.Service.pipe(instances.provide(session))
return yield* generate.generate(input)
}),
command: Effect.fn("Session.command")(function* (input) {
const session = yield* result.get(input.sessionID)
const commands = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.awaitActivation
return yield* Command.Service
}).pipe(instances.provide(session))
const delivery = input.delivery ?? "steer"
yield* commands.execute({
name: input.command,
invocation: {
sessionID: input.sessionID,
prompt: {
text: input.text,
files: input.files,
agents: input.agents,
skills: input.skills,
},
delivery,
},
})
}),
shell: (input) => sessions.forSession(input.sessionID).shell(input),
skill: (input) => sessions.forSession(input.sessionID).skill(input),
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
+3 -3
View File
@@ -13,7 +13,7 @@ import { InstructionBuiltIns } from "../instructions/builtins.js"
import { Location } from "../location.js"
import { McpInstructions } from "../mcp/instructions.js"
import { McpTool } from "../tool/mcp.js"
import { Plugin } from "../plugin.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { ReferenceInstructions } from "../reference/instructions.js"
import { SkillInstructions } from "../skill/instructions.js"
import { Tool } from "../tool.js"
@@ -85,7 +85,7 @@ const layer = Layer.effect(
const mcpTools = yield* McpTool.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
const plugins = yield* Plugin.Service
const plugins = yield* PluginSupervisor.Service
const referenceInstructions = yield* ReferenceInstructions.Service
const skillInstructions = yield* SkillInstructions.Service
const store = yield* SessionStore.Service
@@ -190,7 +190,7 @@ export const node = makeLocationNode({
Location.node,
McpInstructions.node,
McpTool.node,
Plugin.node,
PluginSupervisor.node,
ReferenceInstructions.node,
SessionRunnerModel.node,
SessionModelRequest.node,
+16
View File
@@ -0,0 +1,16 @@
export * as SessionPromptNode from "./prompt-node.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Image } from "../image.js"
import { PluginHooks } from "../plugin/hooks.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { Skill } from "../skill.js"
import { SessionPrompt } from "./prompt.js"
// Keep the supervisor implementation out of the global Session import path.
export const node = makeLocationNode({
service: SessionPrompt.Service,
layer: SessionPrompt.layer,
deps: [FSUtil.node, PluginSupervisor.node, PluginHooks.node, Image.node, Skill.node],
})
+2 -9
View File
@@ -6,14 +6,13 @@ import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { Image } from "../image.js"
import { Mime } from "../mime.js"
import { PluginHooks } from "../plugin/hooks.js"
import { Plugin } from "../plugin.js"
import { PluginSupervisor } from "../plugin/supervisor-service.js"
import { Skill } from "../skill.js"
import { AttachmentError, SkillNotFoundError } from "./error.js"
@@ -28,7 +27,7 @@ export type Input = {
export const make = Effect.fn("SessionPrompt.make")(function* () {
const fs = yield* FSUtil.Service
const plugins = yield* Plugin.Service
const plugins = yield* PluginSupervisor.Service
const hooks = yield* PluginHooks.Service
const image = yield* Image.Service
const skillService = yield* Skill.Service
@@ -206,12 +205,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
export const layer = Layer.effect(Service, make())
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Plugin.node, PluginHooks.node, Image.node, Skill.node],
})
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
function decodeDataURL(uri: string) {
+15
View File
@@ -0,0 +1,15 @@
export * as SessionRevertNode from "./revert-node.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { Snapshot } from "../snapshot.js"
import { SessionRevert } from "./revert.js"
// Keep the supervisor implementation out of the global Session import path.
export const node = makeLocationNode({
service: SessionRevert.Service,
layer: SessionRevert.layer,
deps: [Database.node, Bus.node, PluginSupervisor.node, Snapshot.node],
})
+2 -9
View File
@@ -2,10 +2,9 @@ export * as SessionRevert from "./revert.js"
import { and, asc, eq, gt } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { Plugin } from "../plugin.js"
import { PluginSupervisor } from "../plugin/supervisor-service.js"
import { RelativePath } from "../schema.js"
import { Snapshot } from "../snapshot.js"
import { SessionEvent } from "./event.js"
@@ -35,7 +34,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
export const make = Effect.fn("SessionRevert.make")(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const plugins = yield* Plugin.Service
const plugins = yield* PluginSupervisor.Service
const snapshot = yield* Snapshot.Service
const stage: Interface["stage"] = Effect.fn("SessionRevert.stage")(function* (input) {
@@ -84,12 +83,6 @@ export const make = Effect.fn("SessionRevert.make")(function* () {
export const layer = Layer.effect(Service, make())
export const node = makeLocationNode({
service: Service,
layer,
deps: [Database.node, Bus.node, Plugin.node, Snapshot.node],
})
export const commit = Effect.fn("SessionRevert.commit")(function* (bus: Bus.Interface, session: SessionSchema.Info) {
if (!session.revert) return
yield* bus.publish(SessionEvent.RevertEvent.Committed, {
+3 -3
View File
@@ -23,7 +23,7 @@ import { StepFailedError } from "../error.js"
import { SessionRunnerRetry } from "./retry.js"
import { SessionStep } from "./step.js"
import { ToolOutput } from "../../tool-output.js"
import { Plugin } from "../../plugin.js"
import { PluginSupervisor } from "../../plugin/supervisor.js"
import { MAX_STEPS_PROMPT } from "./max-steps.js"
const CONTINUE_AFTER_INCOMPLETE_STREAM =
@@ -38,7 +38,7 @@ const layer = Layer.effect(
const modelTransport = yield* SessionModelTransport.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const plugins = yield* Plugin.Service
const plugins = yield* PluginSupervisor.Service
const title = yield* SessionTitle.Service
const steps = yield* SessionStep.make
// Title generation starts once input is visible and must not delay model execution.
@@ -301,7 +301,7 @@ export const node = makeLocationNode({
SessionModelTransport.node,
SessionStore.node,
SessionCompaction.node,
Plugin.node,
PluginSupervisor.node,
SessionTitle.node,
Snapshot.node,
ToolOutput.node,
+2 -2
View File
@@ -6,7 +6,7 @@ import type { Model } from "@opencode-ai/schema/model"
import { Event } from "@opencode-ai/schema/event"
import { Bus } from "../bus.js"
import { Instance } from "../instance/service.js"
import { Plugin } from "../plugin.js"
import { PluginSupervisor } from "../plugin/supervisor-service.js"
import { Shell } from "../shell.js"
import { ShellResult } from "../shell/result.js"
import { Skill } from "../skill.js"
@@ -195,7 +195,7 @@ export const make = Effect.fn("Session.make")(function* () {
const running = yield* Effect.gen(function* () {
// Resolve shell services here without pinning Session events to this Location after a move.
const shell = yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const plugins = yield* PluginSupervisor.Service
yield* plugins.awaitActivation
return yield* Shell.Service
}).pipe(instances.provide(session))
+16
View File
@@ -1097,6 +1097,10 @@ describe("Config", () => {
sdk: { repository: "github.com/example/sdk", branch: "main" },
shorthand: "github.com/example/docs",
},
plugins: [
"opencode-helicone-session",
{ package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
],
}),
),
)
@@ -1187,6 +1191,10 @@ describe("Config", () => {
sdk: { repository: "github.com/example/sdk", branch: "main" },
shorthand: "github.com/example/docs",
})
expect(documents[0]?.info.plugins).toEqual([
"opencode-helicone-session",
{ package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
])
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
),
@@ -1251,6 +1259,10 @@ describe("Config", () => {
permission: { read: "allow" },
},
},
plugin: [
"opencode-helicone-session",
["@my-org/audit-plugin", { endpoint: "https://audit.example.com" }],
],
skills: { paths: ["./skills"], urls: ["https://example.com/.well-known/skills/"] },
references: {
docs: { path: "../docs", description: "Use for product documentation", hidden: true },
@@ -1326,6 +1338,10 @@ describe("Config", () => {
request: { body: { temperature: 0.2 } },
permissions: [{ action: "read", resource: "*", effect: "allow" }],
})
expect(documents[0]?.info.plugins).toEqual([
"opencode-helicone-session",
{ package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
])
expect(documents[0]?.info.skills).toEqual(["./skills", "https://example.com/.well-known/skills/"])
expect(documents[0]?.info.references).toEqual({
docs: { path: "../docs", description: "Use for product documentation", hidden: true },
@@ -0,0 +1,13 @@
import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "directory-plugin",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("directory", (agent) => {
agent.description = "Loaded from plugin directory"
agent.mode = "subagent"
})
})
},
})
@@ -249,9 +249,11 @@ describe("ConfigNormalize", () => {
expect(JSON.stringify(result.diagnostics)).not.toContain("TOPSECRET")
})
test("recovers list items for skills, instructions, and permissions", () => {
test("recovers list items for skills, plugins, instructions, and permissions", () => {
const result = normalized({
skills: { paths: ["./skills", 1], urls: [false, "https://example.com/skills"] },
plugin: ["legacy", ["tuple", {}], [1, {}]],
plugins: ["native", { package: "object" }, { package: 1 }],
instructions: ["one", 2, "three"],
permissions: [
{ action: "read", resource: "*", effect: "allow" },
@@ -259,9 +261,15 @@ describe("ConfigNormalize", () => {
],
})
expect(result.encoded.skills).toEqual(["./skills", "https://example.com/skills"])
expect(result.encoded.plugins).toEqual([
"legacy",
{ package: "tuple", options: {} },
"native",
{ package: "object" },
])
expect(result.encoded.instructions).toEqual(["one", "three"])
expect(result.encoded.permissions).toEqual([{ action: "read", resource: "*", effect: "allow" }])
expect(result.diagnostics.filter((item) => item.kind === "invalid")).toHaveLength(4)
expect(result.diagnostics.filter((item) => item.kind === "invalid")).toHaveLength(6)
})
test("omits malformed collection roots instead of synthesizing empty values", () => {
@@ -270,6 +278,7 @@ describe("ConfigNormalize", () => {
providers: "invalid",
references: false,
agents: 1,
plugins: {},
permissions: {},
instructions: {},
})
@@ -280,6 +289,7 @@ describe("ConfigNormalize", () => {
["agents"],
["providers"],
["permissions"],
["plugins"],
["instructions"],
])
})
@@ -506,6 +516,7 @@ describe("ConfigNormalize", () => {
commands: {},
agents: {},
providers: {},
plugins: [],
instructions: [],
experimental: { subagent_depth: 0 },
})
@@ -515,6 +526,7 @@ describe("ConfigNormalize", () => {
commands: {},
agents: {},
providers: {},
plugins: [],
instructions: [],
experimental: { subagent_depth: 0 },
})
+636
View File
@@ -0,0 +1,636 @@
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect } from "bun:test"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Plugin } from "@opencode-ai/core/plugin"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Cause, Effect, Fiber, Layer, Logger, Option, Schedule, Stream } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { tempGlobalLayer } from "../fixture/global"
import { testEffect } from "../lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
]),
)
const staticIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
ConfigPluginSource.node.replace(ConfigPluginSource.empty),
Global.node.replace(tempGlobalLayer),
]),
)
const outdatedNpm = makeGlobalNode({
service: Npm.Service,
layer: Layer.effect(
Npm.Service,
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "outdated-plugin")
let version = "1.0.0"
const installed = () => ({
directory,
entrypoint: pathToFileURL(path.join(directory, "index.js")).href,
version,
revision: version,
})
yield* Effect.promise(async () => {
await fs.mkdir(directory, { recursive: true })
await Bun.write(path.join(directory, "index.js"), 'export default { id: "outdated-plugin", setup() {} }')
})
return Npm.Service.of({
add: () => Effect.sync(installed),
resolve: () => Effect.sync(installed),
check: () => Effect.sync(() => version === "1.0.0"),
update: () => Effect.sync(() => (version = "1.1.0")).pipe(Effect.map(installed)),
which: () => Effect.succeed(undefined),
})
}),
),
deps: [Global.node],
})
const updateIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[Global.node.replace(tempGlobalLayer), Npm.node.replace(outdatedNpm)],
),
)
const coldNpm = makeGlobalNode({
service: Npm.Service,
layer: Layer.effect(
Npm.Service,
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "cold-plugin")
const started = path.join(directory, "started")
const release = path.join(directory, "release")
const entry = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href, revision: "1" }
let installed = false
yield* Effect.promise(async () => {
await fs.mkdir(directory, { recursive: true })
await Bun.write(path.join(directory, "index.js"), 'export default { id: "cold-plugin", setup() {} }')
})
return Npm.Service.of({
add: () =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(started, ""))
yield* waitForFile(release).pipe(Effect.orDie)
installed = true
return entry
}),
resolve: () => Effect.sync(() => (installed ? entry : { directory })),
check: () => Effect.succeed(false),
update: () => Effect.succeed(entry),
which: () => Effect.succeed(undefined),
})
}),
),
deps: [Global.node],
})
const coldIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[Global.node.replace(tempGlobalLayer), Npm.node.replace(coldNpm)],
),
)
describe("PluginSupervisor config", () => {
it.live("applies selectors in order", () =>
withLocation(
{ plugins: ["-opencode.provider.*", "opencode.provider.openai"] },
Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* ready()
expect(
(yield* plugins.list())
.flatMap((plugin) => (plugin.id ? [plugin.id] : []))
.filter((id) => id.startsWith("opencode.provider.")),
).toEqual([Plugin.ID.make("opencode.provider.openai")])
}),
),
)
it.live("allows the built-in Plan agent to be disabled", () =>
withLocation(
{ agents: { plan: { disabled: true } } },
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("plan"))).toBeUndefined()
}),
),
)
it.live("loads configured Promise plugins with options", () =>
withLocation(
{
plugins: [
"-*",
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise"),
options: { description: "Loaded from config" },
},
],
},
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded from config",
mode: "subagent",
})
expect((yield* plugins.list()).find((plugin) => plugin.id === "config-promise-plugin")).toEqual({
id: Plugin.ID.make("config-promise-plugin"),
source: {
type: "local",
path: path.join(import.meta.dir, "../plugin/fixtures/config-promise/index.ts"),
},
state: { status: "active" },
features: { server: true, tui: true },
})
}),
),
)
it.live("disables configured plugins by exported ID", () => {
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise")
return withLocation(
{ plugins: [plugin, "-config-promise-plugin"] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
expect((yield* plugins.list()).map((item) => String(item.id))).not.toContain("config-promise-plugin")
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
}),
)
})
it.live("does not disable configured plugins by package target", () => {
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise")
return withLocation(
{ plugins: [plugin, `-${plugin}`] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((item) => String(item.id))).toContain("config-promise-plugin")
}),
)
})
it.live("loads configured Effect plugins with options", () =>
withLocation(
{
plugins: [
"-*",
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-effect"),
options: { description: "Effect plugin from config" },
},
],
},
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("effect-configured"))).toMatchObject({
description: "Effect plugin from config",
mode: "subagent",
})
}),
),
)
it.live("logs invalid packages and continues loading", () => {
const output: Array<{ target: string; ref: string; diagnostic: string }> = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
if (!Array.isArray(entry.message) || entry.message[0] !== "failed to load plugin") return
const details = entry.message[1]
if (typeof details !== "object" || details === null) return
if (!("target" in details) || typeof details.target !== "string") return
if (!("ref" in details) || typeof details.ref !== "string") return
if (!("cause" in details) || !Cause.isCause(details.cause)) return
output.push({ target: details.target, ref: details.ref, diagnostic: Cause.pretty(details.cause) })
})
return withLocation(
{
plugins: [
"-*",
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid"),
path.join(import.meta.dir, "../plugin/fixtures/load-failure"),
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise"),
options: { description: "Loaded after invalid plugins" },
},
],
},
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded after invalid plugins",
})
expect(output.map((entry) => entry.target)).toEqual([
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts"),
path.join(import.meta.dir, "../plugin/fixtures/load-failure/index.ts"),
])
const failed = (yield* plugins.list()).filter((plugin) => plugin.state.status === "failed")
expect(failed.map((plugin) => plugin.source)).toEqual([
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/load-failure/index.ts") },
])
expect(failed.map((plugin) => plugin.state)).toEqual([
{ status: "failed", error: "Plugin failed to load", ref: output[0]?.ref },
{
status: "failed",
error: "Plugin must export a default definition with an id and an effect or setup function.",
ref: output[1]?.ref,
},
{ status: "failed", error: "Plugin failed to load", ref: output[2]?.ref },
])
output.forEach((entry) => expect(entry.ref).toMatch(/^err_[0-9a-f]{8}$/))
expect(new Set(output.map((entry) => entry.ref)).size).toBe(3)
expect(output[2]?.diagnostic).toContain("private plugin loader details")
expect(JSON.stringify(failed)).not.toContain("private plugin loader details")
}),
).pipe(Effect.provide(Logger.layer([logger])))
})
it.live("loads auto-discovered plugin files", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("directory"))).toMatchObject({
description: "Loaded from plugin directory",
})
}),
true,
),
)
it.live("loads conventional auto-discovered plugin entrypoints", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
expect(ids).toContain("package-index-ts")
expect(ids).toContain("package-index-js")
expect(ids).not.toContain("package-custom-entry")
}),
false,
async (directory) => {
await Promise.all([
writeDiscoveredPackage(directory, "ts", { "index.ts": "package-index-ts" }),
writeDiscoveredPackage(directory, "js", { "index.js": "package-index-js" }),
writeDiscoveredPackage(directory, "custom", { "entry.ts": "package-custom-entry" }),
])
},
),
)
it.live("keeps auto-discovered package entrypoints inside the package directory", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
expect(ids).toContain("contained-fallback")
expect(ids).toContain("symlink-fallback")
expect(ids).not.toContain("escaped-entrypoint")
}),
false,
async (directory) => {
await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
await fs.writeFile(path.join(directory, ".opencode", "escape.js"), discoveredPlugin("escaped-entrypoint"))
await writeDiscoveredPackage(directory, "contained", { "index.js": "contained-fallback" })
await writeDiscoveredPackage(directory, "symlink", { "index.js": "symlink-fallback" })
await fs.symlink(
path.join(directory, ".opencode", "escape.js"),
path.join(directory, ".opencode", "plugins", "symlink", "index.ts"),
)
},
),
)
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(define({ id: "static-sdk", effect: () => Effect.void }))
yield* withLocation(
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise")] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const inventory = yield* plugins.list()
const ids = inventory.map((plugin) => String(plugin.id))
expect(ids).toContain("opencode.agent")
expect(ids).toContain("static-sdk")
expect(ids).not.toContain("config-promise-plugin")
expect(inventory.find((plugin) => plugin.id === "static-sdk")?.source).toEqual({ type: "sdk" })
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
}),
true,
)
}),
)
it.live("reloads an auto-discovered plugin when its file changes", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
const bus = yield* Bus.Service
const location = yield* Location.Service
const plugins = yield* Plugin.Service
const file = path.join(location.directory, ".opencode", "plugin", "mutable.ts")
const first = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id
expect(first).toBeDefined()
expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("first")
const changed = yield* bus
.subscribe(Plugin.Event.Updated)
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
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* Fiber.join(changed).pipe(Effect.timeout("5 seconds"))
const current = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id
expect(current).toBe(first)
expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("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"))
},
),
)
it.live("reloads a configured plugin when its entrypoint changes", () =>
withLocation(
{ plugins: ["-*", "./external"] },
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
const bus = yield* Bus.Service
const location = yield* Location.Service
const file = path.join(location.directory, "external", "index.ts")
expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("first")
const changed = yield* bus
.subscribe(Plugin.Event.Updated)
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
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* Fiber.join(changed).pipe(Effect.timeout("5 seconds"))
expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("second")
}),
false,
async (directory) => {
// Outside any {plugin,plugins} config-source directory, so only the
// configured-entrypoint watch can observe the edit.
const external = path.join(directory, "external")
await fs.mkdir(external, { recursive: true })
await fs.writeFile(path.join(external, "index.ts"), mutablePlugin("first"))
},
),
)
it.live("skips configured local files", () =>
withLocation(
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).not.toContain("config-promise-plugin")
}),
),
)
it.live("applies explicit removals after auto-discovery", () =>
withLocation(
{ plugins: ["-*"] },
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("directory"))).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"),
path.join(import.meta.dir, "../plugin/fixtures/variant-source"),
],
},
Effect.gen(function* () {
yield* ready()
const registry = yield* Plugin.Service
const ids = (yield* registry.list()).map((plugin) => String(plugin.id))
expect(ids.indexOf("opencode.agent")).toBeLessThan(ids.indexOf("sdk-order"))
expect(ids.indexOf("sdk-order")).toBeLessThan(ids.indexOf("config-promise-plugin"))
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(Provider.ID.make("configured"), Model.ID.make("glm-5.2")))?.variants,
).toEqual([
expect.objectContaining({ id: "high", headers: { custom: "true" } }),
expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
])
}),
)
}),
)
it.live("allows variant generation to be disabled", () =>
withLocation(
{
plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source"), "-opencode.variant"],
},
Effect.gen(function* () {
yield* ready()
const registry = yield* Plugin.Service
expect((yield* registry.list()).map((plugin) => String(plugin.id))).not.toContain("opencode.variant")
const catalog = yield* Catalog.Service
expect((yield* catalog.model.get(Provider.ID.make("configured"), Model.ID.make("glm-5.2")))?.variants).toEqual([
expect.objectContaining({ id: "high", headers: { custom: "true" } }),
])
}),
),
)
it.live("unblocks awaitActivation when plugin activation fails", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
yield* withLocation(
undefined,
Effect.gen(function* () {
yield* ready().pipe(Effect.timeout("2 seconds"))
}),
)
}),
)
updateIt.live("marks active package plugins as outdated after a background check", () =>
withLocation(
{ plugins: ["outdated-plugin"] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const source = yield* Effect.suspend(() => plugins.list()).pipe(
Effect.map((items) => items.find((item) => item.id === "outdated-plugin")?.source),
Effect.filterOrFail((source) => source?.type === "package" && source.outdated === true),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("2 seconds"),
)
expect(source).toEqual({ type: "package", target: "outdated-plugin", version: "1.0.0", outdated: true })
}),
),
)
coldIt.live("activates available plugins before a missing package finishes installing", () =>
withLocation(
{ plugins: ["cold-plugin"] },
Effect.gen(function* () {
const global = yield* Global.Service
yield* waitForFile(path.join(global.tmp, "cold-plugin", "started"))
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("opencode.provider.openai")
const supervisor = yield* PluginSupervisor.Service
expect(Option.isNone(yield* supervisor.awaitActivation.pipe(Effect.timeoutOption("20 millis")))).toBeTrue()
yield* Effect.promise(() => Bun.write(path.join(global.tmp, "cold-plugin", "release"), ""))
yield* supervisor.awaitActivation.pipe(Effect.timeout("2 seconds"))
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("cold-plugin")
}),
),
)
})
const ready = Effect.fnUntraced(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.awaitActivation
})
const waitForFile = (file: string) =>
Effect.promise(() => Bun.file(file).exists()).pipe(
Effect.filterOrFail((exists) => exists),
Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }),
Effect.timeout("2 seconds"),
)
function withLocation<A, E, R>(
config: unknown,
effect: Effect.Effect<A, E, R>,
fixtures = false,
prepare?: (directory: string) => Promise<void>,
) {
return Effect.acquireDisposable(Effect.promise(() => tmpdir())).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/promise/index.ts")).href
return `
import { Plugin } from ${JSON.stringify(plugin)}
export default Plugin.define({
id: "mutable-plugin",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("mutable", (agent) => {
agent.description = ${JSON.stringify(description)}
agent.mode = "subagent"
})
})
},
})
`
}
function discoveredPlugin(id: string) {
return `export default { id: ${JSON.stringify(id)}, setup() {} }`
}
async function writeDiscoveredPackage(directory: string, name: string, files: Record<string, string>) {
const plugin = path.join(directory, ".opencode", "plugins", name)
await fs.mkdir(plugin, { recursive: true })
await Promise.all(
Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))),
)
}
+13 -6
View File
@@ -29,6 +29,11 @@ const describeNative = process.env.CI ? describe.skip : describe
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
const configLayer = Config.testLayer()
const pluginNode = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ awaitActivation: Effect.void })),
deps: [],
})
function withNative(native: Watcher.NativeInterface) {
return Effect.provide(Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))))
@@ -124,18 +129,18 @@ function provide(
vcs?: Location.Interface["vcs"],
watcher?: Layer.Layer<Watcher.Service>,
config: Layer.Layer<Config.Service> = configLayer,
plugins?: typeof PluginSupervisor.node,
plugins: typeof pluginNode = pluginNode,
) {
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
)
const built = AppNodeBuilder.build(
LayerNode.group([PluginSupervisor.node, LocationWatcher.node, LocationWatcherPolicy.node, Bus.node, Config.node]),
LayerNode.group([LocationWatcher.node, LocationWatcherPolicy.node, Bus.node, Config.node]),
[
Config.node.replace(config),
Location.node.replace(locationLayer),
PluginSupervisor.node.replace(plugins ?? Layer.empty),
PluginSupervisor.node.replace(plugins),
...(watcher ? ([Watcher.node.replace(watcher)] as const) : []),
],
)
@@ -149,7 +154,7 @@ function withTmp<A, E, R>(
init?: (directory: string) => Promise<void>
watcher?: Layer.Layer<Watcher.Service>
config?: Layer.Layer<Config.Service>
plugins?: typeof PluginSupervisor.node
plugins?: typeof pluginNode
},
) {
return Effect.acquireRelease(
@@ -302,11 +307,13 @@ describe("LocationWatcher subscriptions", () => {
}),
)
const plugins = makeLocationNode({
name: "test/watcher-plugins",
layer: Layer.effectDiscard(
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
yield* policy.transform((draft) => draft.add([".git"]))
return PluginSupervisor.Service.of({ awaitActivation: Effect.void })
}),
),
deps: [LocationWatcherPolicy.node],
@@ -2,8 +2,8 @@ import { Bus } from "@opencode-ai/core/bus"
import { Image } from "@opencode-ai/core/image"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Reference } from "@opencode-ai/core/reference"
import { SessionPrompt } from "@opencode-ai/core/session/prompt"
import { Skill } from "@opencode-ai/core/skill"
@@ -30,7 +30,9 @@ export const promptLocationNode = makeGlobalNode({
replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))],
}),
Layer.succeed(FSUtil.Service, fs),
Layer.mock(Plugin.Service, { awaitActivation: Effect.void }),
Layer.succeed(PluginSupervisor.Service, {
awaitActivation: Effect.void,
}),
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
),
),
+2 -2
View File
@@ -6,7 +6,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Info } from "@opencode-ai/schema/config"
@@ -43,7 +43,7 @@ function withFormatter<A, E, R>(
).pipe(
Effect.andThen(
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const plugins = yield* PluginSupervisor.Service
yield* plugins.awaitActivation
return yield* body(yield* Formatter.Service, directory)
}).pipe(
+104
View File
@@ -0,0 +1,104 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { Duration, Effect, Layer, LayerMap } from "effect"
import { Plugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Instance } from "@opencode-ai/core/instance"
import { InstancePlugins } from "@opencode-ai/core/plugin/instance"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { Database } from "../src/database/database"
import { Bus } from "../src/bus"
const agentPlugin = (pluginID: string, agentID: string) =>
Plugin.define({
id: pluginID,
effect: (ctx) => ctx.agent.transform((agents) => agents.update(Agent.ID.make(agentID), () => {})),
})
// A host-owned assignment in miniature: the map decides per ref which plugins
// an instance is born with, the way an embedder will per Slack thread.
const instances = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
(ref: Location.Ref) =>
Instance.layer(ref, {
plugins: path.basename(ref.directory) === "thread-a" ? [agentPlugin("thread-a-plugin", "thread-a-agent")] : [],
replacements: [Global.node.replace(tempGlobalLayer)],
}),
{ idleTimeToLive: Duration.infinity },
),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
LocationServiceMap.node.replace(instances),
]),
)
describe("InstancePlugins", () => {
it.live("binds plugins to one instance without leaking to siblings", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const sdk = yield* SdkPlugins.Service
yield* sdk.register(agentPlugin("global-plugin", "global-agent"))
const dirA = path.join(dir.path, "thread-a")
const dirB = path.join(dir.path, "thread-b")
yield* Effect.promise(() => fs.mkdir(dirA))
yield* Effect.promise(() => fs.mkdir(dirB))
const refA = Location.Ref.make({ directory: AbsolutePath.make(dirA) })
const refB = Location.Ref.make({ directory: AbsolutePath.make(dirB) })
const agents = (ref: Location.Ref) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.awaitActivation
const service = yield* Agent.Service
return {
bound: yield* service.get(Agent.ID.make("thread-a-agent")),
global: yield* service.get(Agent.ID.make("global-agent")),
}
}).pipe(Effect.scoped, Effect.provide(locations.get(ref)))
const a = yield* agents(refA)
expect(a.bound).toBeDefined()
expect(a.global).toBeDefined()
const b = yield* agents(refB)
expect(b.bound).toBeUndefined()
expect(b.global).toBeDefined()
// Eviction and rebuild re-bind the same list.
yield* locations.invalidate(refA)
const rebuilt = yield* agents(refA)
expect(rebuilt.bound).toBeDefined()
expect(rebuilt.global).toBeDefined()
}),
),
),
)
})
describe("InstancePlugins.bound", () => {
test("rejects duplicate ids in one list", () => {
const plugin = Plugin.define({ id: "dup", effect: () => Effect.void })
expect(() => InstancePlugins.bound([plugin, plugin])).toThrow("duplicate instance plugin ids: dup")
})
})
+54 -7
View File
@@ -10,21 +10,24 @@ import { Instance } from "@opencode-ai/core/instance"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { Database } from "../src/database/database"
import { Bus } from "../src/bus"
// Config the host hands the vanilla instance explicitly must survive discovery: false.
// Config the host hands the vanilla instance explicitly: a value and an
// explicit plugin removal, both of which must survive discovery: false.
const hostConfig: LayerNode.Replacements = [
Config.node.replace(
Config.configured({
project: false,
global: false,
content: JSON.stringify({ shell: "vanilla-host" }),
content: JSON.stringify({ shell: "vanilla-host", plugins: ["-opencode.tool.shell"] }),
}),
),
]
@@ -47,7 +50,7 @@ const instances = Layer.effect(
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
LocationServiceMap.node.replace(instances),
]),
@@ -73,10 +76,11 @@ describe("Instance vanilla", () => {
const read = (ref: Location.Ref) =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.awaitActivation
const config = yield* Config.Service
const discovery = yield* InstructionDiscovery.Service
const tools = yield* Tool.Service
const entries = yield* config.entries()
return {
documents: entries.filter(
@@ -84,20 +88,24 @@ describe("Instance vanilla", () => {
),
instructions: yield* discovery.list(),
shell: Config.latest(entries, "shell"),
toolNames: (yield* tools.snapshot()).definitions.map((definition) => definition.name),
}
}).pipe(Effect.scoped, Effect.provide(locations.get(ref)))
const vanilla = yield* read(yield* plant("vanilla"))
expect(vanilla.documents).toEqual([])
expect(vanilla.instructions).toEqual([])
// Host-injected content survives discovery: false.
// Host-injected content survives discovery: false, including its
// explicit plugin operations.
expect(vanilla.shell).toBe("vanilla-host")
expect(vanilla.toolNames).not.toContain("shell")
// Bare vanilla: the defaults themselves, with no caller Config.
const bare = yield* read(yield* plant("bare"))
expect(bare.documents).toEqual([])
expect(bare.instructions).toEqual([])
expect(bare.shell).toBeUndefined()
expect(bare.toolNames).toContain("shell")
const discovery = yield* read(yield* plant("discovery"))
expect(discovery.documents.length).toBeGreaterThan(0)
@@ -105,6 +113,45 @@ describe("Instance vanilla", () => {
Array.isArray(discovery.instructions) &&
discovery.instructions.some((file) => file.path.endsWith("AGENTS.md")),
).toBe(true)
expect(discovery.toolNames).toContain("shell")
}),
),
),
)
it.live("does not execute ambient plugin modules", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const directory = path.join(dir.path, "vanilla")
const marker = path.join(directory, "ambient-loaded.txt")
// A plugin module whose import writes a sentinel: project-marker
// discovery used to import it during vanilla boot.
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, ".opencode", "plugins"), { recursive: true })
await fs.writeFile(
path.join(directory, ".opencode", "plugins", "ambient.ts"),
[
'import { writeFile } from "node:fs/promises"',
`await writeFile(${JSON.stringify(marker)}, "loaded")`,
'export default { id: "ambient-plugin", setup() {} }',
].join("\n"),
)
})
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.awaitActivation
const config = yield* Config.Service
// Only the pathless host-injected document; nothing file-backed.
const entries = yield* config.entries()
expect(entries.filter((entry) => "path" in entry && typeof entry.path === "string")).toEqual([])
}).pipe(Effect.scoped, Effect.provide(locations.get(ref)))
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
}),
),
),
+533 -28
View File
@@ -3,8 +3,23 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Duration, Effect, Equal, Hash, Layer, LayerMap, Option, RcMap, Schema, Stream } from "effect"
import {
DateTime,
Deferred,
Duration,
Effect,
Equal,
Fiber,
Hash,
Layer,
LayerMap,
Option,
RcMap,
Schema,
Stream,
} from "effect"
import { TestClock } from "effect/testing"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -14,7 +29,10 @@ import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/loc
import { LocationActivity } from "@opencode-ai/core/location-activity"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { Mcp } from "@opencode-ai/core/mcp/index"
import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -36,6 +54,11 @@ const it = testEffect(
Global.node.replace(tempGlobalLayer),
]),
)
const itWithSdk = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
]),
)
const activityLocations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
@@ -157,6 +180,421 @@ describe("LocationServiceMap", () => {
),
)
itWithSdk.live("preserves embedded SDK plugins after Location eviction", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
const locations = yield* LocationServiceMap.Service
const id = Agent.ID.make("persistent-sdk-agent")
const plugin = EffectPlugin.define({
id: "persistent-sdk-plugin",
effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})),
})
yield* sdk.register(plugin)
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const read = Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.awaitActivation
const agents = yield* Agent.Service
return yield* agents.get(id)
})
expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined()
yield* locations.invalidate(ref)
expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined()
}),
),
),
)
itWithSdk.live("waits for explorer activation to complete", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const sdk = yield* SdkPlugins.Service
yield* sdk.register(
EffectPlugin.define({
id: "blocked-initial-activation",
effect: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))),
}),
)
const locations = yield* LocationServiceMap.Service
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* Deferred.await(started)
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild,
)
expect(activationFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(activationFiber)
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.timeout("1 second"),
)
const explorer = yield* Effect.gen(function* () {
const agents = yield* Agent.Service
return yield* agents.resolve("explore")
}).pipe(Effect.provide(context))
expect(explorer).toBeDefined()
expect(explorer?.permissions.length).toBeGreaterThan(0)
}),
),
),
)
itWithSdk.live("reruns activation for SDK plugins registered during startup", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const releaseSecond = yield* Deferred.make<void>()
const sdk = yield* SdkPlugins.Service
yield* sdk.register(
EffectPlugin.define({
id: "fixed-target-first-plugin",
effect: () =>
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
}),
)
const locations = yield* LocationServiceMap.Service
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* Deferred.await(firstStarted)
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
yield* Effect.yieldNow
yield* sdk.register(
EffectPlugin.define({
id: "fixed-target-second-plugin",
effect: () =>
Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))),
}),
)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
expect(activationFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseSecond, undefined)
yield* Fiber.join(activationFiber)
}),
),
),
)
itWithSdk.live("reruns activation for Config updates during startup", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const activations = { count: 0 }
const file = path.join(dir.path, "opencode.json")
yield* Effect.promise(() => fs.writeFile(file, "{}"))
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const releaseSecond = yield* Deferred.make<void>()
const sdk = yield* SdkPlugins.Service
yield* sdk.register(
EffectPlugin.define({
id: "blocked-config-reload",
effect: () =>
Effect.sync(() => ++activations.count).pipe(
Effect.flatMap((activation) =>
activation === 1
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst)))
: Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))),
),
),
}),
)
const locations = yield* LocationServiceMap.Service
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* Deferred.await(firstStarted)
const bus = yield* Bus.Service
const updated = yield* bus.subscribe(Config.Event.Updated).pipe(
Stream.filter((event) => event.location?.directory === dir.path),
Stream.runHead,
Effect.forkChild({ startImmediately: true }),
)
yield* Effect.promise(() =>
fs.writeFile(
file,
JSON.stringify({ plugins: [path.join(import.meta.dir, "plugin/fixtures/config-effect")] }),
),
)
yield* Fiber.join(updated)
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild,
)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
expect(activationFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseSecond, undefined)
yield* Fiber.join(activationFiber)
expect(activations.count).toBe(2)
}),
),
),
)
itWithSdk.live("keeps awaitActivation pending while startup updates continue", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
const bus = yield* Bus.Service
yield* Effect.forEach(
Array.from({ length: 5 }),
() => bus.publish(SdkPlugins.Updated, {}).pipe(Effect.andThen(Effect.sleep("50 millis"))),
{ discard: true },
)
expect(activationFiber.pollUnsafe()).toBeUndefined()
yield* Fiber.join(activationFiber)
}),
),
),
)
itWithSdk.live("does not reload plugins when config updates leave plugin operations unchanged", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const activations = { count: 0 }
const sdk = yield* SdkPlugins.Service
yield* sdk.register(
EffectPlugin.define({
id: "unchanged-config-plugin",
effect: () => Effect.sync(() => ++activations.count).pipe(Effect.asVoid),
}),
)
const locations = yield* LocationServiceMap.Service
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(Effect.provide(context))
expect(activations.count).toBe(1)
yield* Bus.Service.use((bus) => bus.publish(Config.Event.Updated, {})).pipe(Effect.provide(context))
yield* Effect.sleep("200 millis")
expect(activations.count).toBe(1)
}),
),
),
)
itWithSdk.live("keeps awaitActivation pending while later hot reload runs", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(Effect.provide(context))
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const completed = yield* Deferred.make<void>()
const sdk = yield* SdkPlugins.Service
yield* sdk.register(
EffectPlugin.define({
id: "post-ready-plugin",
effect: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(Deferred.succeed(completed, undefined)),
),
}),
)
yield* Deferred.await(started)
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
expect(activationFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(activationFiber)
yield* Deferred.await(completed)
}),
),
),
)
itWithSdk.live("does not cancel activation when an awaitActivation waiter is interrupted", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const completed = yield* Deferred.make<void>()
const sdk = yield* SdkPlugins.Service
yield* sdk.register(
EffectPlugin.define({
id: "interrupted-waiter-plugin",
effect: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(Deferred.succeed(completed, undefined)),
),
}),
)
const locations = yield* LocationServiceMap.Service
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
yield* Deferred.await(started)
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
yield* Fiber.interrupt(activationFiber)
yield* Deferred.succeed(release, undefined)
yield* Deferred.await(completed)
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(context),
Effect.timeout("500 millis"),
)
}),
),
),
)
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* Plugin.Service
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.awaitActivation
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* Plugin.Service
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.awaitActivation
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")],
}),
),
)
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* registry.list()).some((plugin) => plugin.state.status === "failed")) break
yield* Effect.sleep("20 millis")
}
expect(yield* registry.list()).toEqual([
{
id: Plugin.ID.make("failing-plugin"),
source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing/index.ts") },
state: { status: "failed", error: expect.stringContaining("plugin failed") },
features: { server: true },
},
])
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()])),
@@ -271,8 +709,8 @@ describe("LocationServiceMap", () => {
yield* Reference.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.awaitActivation
const registry = yield* Tool.Service
return {
providers: yield* catalog.provider.all(),
@@ -329,31 +767,6 @@ describe("LocationServiceMap", () => {
),
)
it.live("allows the built-in Plan agent to be disabled", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(
path.join(dir.path, "opencode.json"),
JSON.stringify({ agents: { plan: { disabled: true } } }),
),
)
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("plan"))).toBeUndefined()
}).pipe(
Effect.provide(
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
),
)
}),
),
),
)
it.live("rejects an unavailable selected model during location model resolution", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -506,4 +919,96 @@ describe("LocationServiceMap", () => {
),
),
)
it.live("installs public plugins into a location", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const reviewer = EffectPlugin.define({
id: "reviewer",
effect: (ctx) =>
ctx.agent
.transform((agent) => {
agent.update("reviewer", (item) => {
item.description = "Reviews code"
item.mode = "subagent"
})
})
.pipe(Effect.asVoid),
})
yield* plugins.activate([{ ...reviewer, revision: "1" }])
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
description: "Reviews code",
mode: "subagent",
})
}).pipe(
Effect.scoped,
Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
),
),
),
)
itWithSdk.live("lets public plugins mutate configured and runtime MCP servers", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const url = "https://example.com/mcp"
yield* Effect.promise(() =>
fs.writeFile(
path.join(dir.path, "opencode.json"),
JSON.stringify({ mcp: { servers: { example: { type: "remote", url, disabled: true } } } }),
),
)
const observed: Record<string, boolean | undefined> = {}
const sdk = yield* SdkPlugins.Service
yield* sdk.register(
EffectPlugin.define({
id: "mcp-codemode-policy",
effect: (ctx) =>
ctx.mcp
.transform((mcp) => {
for (const [name, server] of mcp.list()) {
if (server.type !== "remote" || new URL(server.url).hostname !== "example.com") continue
mcp.update(name, (current) => {
current.codemode = false
observed[name] = current.codemode
})
}
})
.pipe(Effect.asVoid),
}),
)
yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
const mcp = yield* Mcp.Service
yield* supervisor.awaitActivation
expect(observed.example).toBe(false)
yield* mcp.add("dynamic", {
type: "remote",
url: "https://example.com/dynamic",
disabled: true,
})
expect(observed.dynamic).toBe(false)
expect((yield* mcp.servers()).map((server) => String(server.name))).toEqual(["dynamic", "example"])
}).pipe(
Effect.scoped,
Effect.provide(
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
),
)
}),
),
),
)
})
+139 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -9,6 +9,8 @@ import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionTable } from "@opencode-ai/core/permission/sql"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import type { PermissionEvaluation } from "@opencode-ai/plugin/effect/permission"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -26,7 +28,15 @@ const current = Layer.succeed(
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionStore.node, PermissionSaved.node, Agent.node, Permission.node]),
LayerNode.group([
Database.node,
Bus.node,
SessionStore.node,
PermissionSaved.node,
Agent.node,
PluginHooks.node,
Permission.node,
]),
[Location.node.replace(current)],
),
)
@@ -148,6 +158,74 @@ describe("Permission", () => {
}),
)
it.effect("lets plugins review allow and ask decisions without overriding configured denies", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const seen: string[] = []
yield* hooks.register("permission", "evaluate", (event) =>
Effect.sync(() => {
seen.push(event.effect)
event.effect = event.action === "write" ? "deny" : "allow"
event.message = "Reviewed by policy"
}),
)
const service = yield* Permission.Service
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" })
yield* setRules([])
expect(yield* service.ask(assertion({ id: Permission.ID.create("per_ask") }))).toMatchObject({ effect: "allow" })
expect(yield* service.list()).toEqual([])
const blocked = yield* service
.assert(assertion({ id: Permission.ID.create("per_write"), action: "write" }))
.pipe(Effect.flip)
expect(blocked).toBeInstanceOf(Permission.BlockedError)
expect(blocked.message).toBe("Reviewed by policy")
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion({ id: Permission.ID.create("per_deny") }))).toMatchObject({ effect: "deny" })
expect(seen).toEqual(["allow", "ask", "ask"])
}),
)
it.effect("publishes the reviewer message when a plugin escalates to ask", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const hooks = yield* PluginHooks.Service
yield* hooks.register("permission", "evaluate", (event) =>
Effect.sync(() => {
event.effect = "ask"
event.message = "Confirm production access"
}),
)
const service = yield* Permission.Service
const result = yield* service.ask(assertion())
expect(result.effect).toBe("ask")
expect(yield* service.get(result.id)).toMatchObject({ message: "Confirm production access" })
}),
)
it.effect("allows cancellation while a permission reviewer is running", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const hooks = yield* PluginHooks.Service
const started = yield* Deferred.make<void>()
yield* hooks.register("permission", "evaluate", () =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
)
const service = yield* Permission.Service
const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
}),
)
it.effect("allows managed output reads without granting external directory access", () =>
Effect.gen(function* () {
yield* setup([
@@ -308,6 +386,55 @@ describe("Permission", () => {
}),
)
for (const effect of ["ask", "deny", "allow"] as const) {
it.effect(`reevaluates pending requests with hooks after always: ${effect}`, () =>
Effect.gen(function* () {
yield* setup()
yield* setup([], Session.ID.make("ses_other"))
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.ID.make("reviewer"), (agent) => {
agent.permissions = []
}),
)
const context = {
sessionID: Session.ID.make("ses_other"),
agent: Agent.ID.make("reviewer"),
action: "read",
resources: ["src/protected.ts", "src/private.ts"],
metadata: { purpose: "protected" },
source: { type: "tool", messageID: "msg_other", id: "call_other" },
} satisfies Permission.AssertInput
const hooks = yield* PluginHooks.Service
const seen: PermissionEvaluation[] = []
yield* hooks.register("permission", "evaluate", (event) =>
Effect.sync(() => {
seen.push({ ...event })
if (event.effect === "allow") event.effect = effect
}),
)
const selected = yield* waitForRequest({ save: ["src/*"] })
const other = yield* waitForRequest({ id: Permission.ID.create("per_other"), ...context })
expect(yield* selected.service.list()).toEqual([selected.request, other.request])
yield* selected.service.reply({ requestID: selected.request.id, reply: "always" })
yield* Fiber.join(selected.fiber)
expect(yield* selected.service.list()).toEqual(effect === "allow" ? [] : [other.request])
expect(seen).toMatchObject([
{ sessionID: selected.request.sessionID, effect: "ask" },
{ ...context, effect: "ask" },
{ ...context, effect: "allow" },
])
if (effect !== "allow") {
expect(other.fiber.pollUnsafe()).toBeUndefined()
yield* other.service.reply({ requestID: other.request.id, reply: "once" })
}
yield* Fiber.join(other.fiber)
expect(yield* selected.service.list()).toEqual([])
}),
)
}
for (const guard of ["configured deny", "missing Session"] as const) {
it.effect(`skips pending auto-approval after always for ${guard}`, () =>
Effect.gen(function* () {
@@ -336,10 +463,20 @@ describe("Permission", () => {
const { db } = yield* Database.Service
yield* db.delete(SessionTable).where(eq(SessionTable.id, other.request.sessionID)).run().pipe(Effect.orDie)
}
const hooks = yield* PluginHooks.Service
const seen: PermissionEvaluation[] = []
yield* hooks.register("permission", "evaluate", (event) =>
Effect.sync(() => {
seen.push({ ...event })
event.effect = "allow"
}),
)
yield* selected.service.reply({ requestID: selected.request.id, reply: "always" })
yield* Fiber.join(selected.fiber)
expect(yield* selected.service.list()).toEqual([other.request])
expect(other.fiber.pollUnsafe()).toBeUndefined()
expect(seen).toEqual([])
yield* Fiber.interrupt(other.fiber)
expect(yield* selected.service.list()).toEqual([])
}),
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Session } from "@opencode-ai/schema/session"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect } from "effect"
import { PluginHooks } from "../src/plugin/hooks"
import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(PluginHooks.node))
describe("PluginHooks", () => {
it.effect("registers scoped session hooks and triggers them sequentially", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const seen: string[] = []
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
seen.push("first")
event.system.push(SystemPart.make("second"))
}),
)
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
seen.push(event.system[1]?.text ?? "missing")
event.messages = [Message.user("changed")]
}),
)
const event = {
sessionID: Session.ID.make("ses_hooks"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
system: [SystemPart.make("first")],
messages: [Message.user("original")],
tools: {},
generation: {},
providerOptions: {},
}
expect(yield* hooks.trigger("session", "context", event)).toBe(event)
expect(seen).toEqual(["first", "second"])
expect(event.messages).toEqual([Message.user("changed")])
}),
)
it.effect("mutates shell creation input", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
yield* hooks.register("shell", "create.before", (event) =>
Effect.sync(() => {
event.command = "echo changed"
}),
)
const event = {
command: "echo original",
cwd: "/tmp",
timeout: 0,
shell: "/bin/sh",
env: {},
}
expect(yield* hooks.trigger("shell", "create.before", event)).toBe(event)
expect(event.command).toBe("echo changed")
}),
)
})
@@ -0,0 +1,32 @@
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { SessionDomain } from "@opencode-ai/plugin/promise/session"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Effect } from "effect"
export function effectPrompt(context: Context) {
context.session.hook("prompt", (event) =>
Effect.sync(() => {
event.prompt.files ??= []
event.prompt.files.push({ uri: "file:///policy.md" })
event.delivery = "queue"
// @ts-expect-error Admission identity cannot be rewritten.
event.sessionID = Session.ID.make("ses_other")
}),
)
// @ts-expect-error Prompt admission has no resolved model to filter by provider.
context.session.hook("prompt", () => Effect.void, { providerID: "openai" })
context.session.hook("context", () => Effect.void, { providerID: "openai" })
}
export function promisePrompt(session: SessionDomain) {
session.hook("prompt", (event) => {
event.prompt.text = "Prepared"
event.metadata = { source: "plugin" }
// @ts-expect-error Admission identity cannot be rewritten.
event.messageID = SessionMessage.ID.make("msg_other")
})
// @ts-expect-error Prompt admission has no resolved model to filter by provider.
session.hook("prompt", () => {}, { providerID: "openai" })
session.hook("context", () => {}, { providerID: "openai" })
}
+829 -95
View File
@@ -1,121 +1,855 @@
import { expect } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { Command } from "@opencode-ai/core/command"
import { describe, expect } from "bun:test"
import { ToolFailure } from "@opencode-ai/ai"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginModule } from "@opencode-ai/core/plugin/module"
import { Session } from "@opencode-ai/schema/session"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Location } from "@opencode-ai/core/location"
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool"
import { Vcs } from "@opencode-ai/core/vcs"
import { Pty } from "@opencode-ai/schema/pty"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer)
it.live("loads a local plugin with its configured options", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
yield* plugins.awaitActivation
const definition = yield* PluginModule.load({
type: "add",
target: path.join(import.meta.dir, "plugin/fixtures/greeting.ts"),
options: { description: "Configured greeting" },
})
if ("pending" in definition) return yield* Effect.die("Local plugin was not loaded")
yield* plugins.activate([definition])
class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSecret") {}
expect(yield* commands.get("greet")).toMatchObject({ description: "Configured greeting" })
}),
)
const generation = <R>(plugin: EffectPlugin.Plugin<R>, revision = "1") => ({ ...plugin, revision })
it.effect("unloading a plugin removes its commands and runs cleanup", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
let cleaned = false
yield* plugins.activate([
{
id: "greeting",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() =>
describe("Plugin", () => {
it.effect("routes experimental terminal reads through the runtime cell without wrapping results", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const runtime = yield* PluginRuntime.Service
const cell = PluginRuntime.makeCell()
const host = yield* PluginHost.make(plugins).pipe(Effect.provide(PluginRuntime.layerWithCell(cell)))
const sessionID = Session.ID.make("ses_terminal")
const pending = host.experimental.terminal.read({ sessionID, lines: 3 })
const seen: unknown[] = []
const terminal = {
ptyID: Pty.ID.make("pty_terminal"),
title: "Build",
cwd: "/workspace",
foregroundProcess: null,
screen: { text: "one\ntwo\nthree", cols: 80, rows: 2, cursor: { x: 3, y: 1 } },
}
const error = new PersistentPty.UnavailableError({ message: "terminal daemon unavailable" })
cell.runtime = {
...runtime,
persistentPty: {
read: (id, lines) => {
seen.push({ sessionID: id, lines })
if (id === Session.ID.make("ses_failure")) return Effect.fail(error)
return Effect.succeed(id === sessionID ? terminal : null)
},
},
}
expect(Object.keys(host.experimental)).toEqual(["terminal"])
expect(Object.keys(host.experimental.terminal)).toEqual(["read"])
expect(yield* pending).toBe(terminal)
expect(yield* host.experimental.terminal.read({ sessionID })).toBe(terminal)
expect(yield* host.experimental.terminal.read({ sessionID: Session.ID.make("ses_empty") })).toBeNull()
expect(
yield* host.experimental.terminal.read({ sessionID: Session.ID.make("ses_failure") }).pipe(Effect.flip),
).toBe(error)
expect(seen).toEqual([
{ sessionID, lines: 3 },
{ sessionID, lines: undefined },
{ sessionID: Session.ID.make("ses_empty"), lines: undefined },
{ sessionID: Session.ID.make("ses_failure"), lines: undefined },
])
cell.runtime = undefined
expect(Exit.isFailure(yield* pending.pipe(Effect.exit))).toBe(true)
}),
)
it.effect("exposes the current location to activated plugins", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const location = yield* Location.Service
const seen: Location.Info[] = []
yield* plugins.activate([
generation(
EffectPlugin.define({
id: "location-context",
effect: (ctx) =>
Effect.sync(() => {
cleaned = true
seen.push(ctx.location)
}),
)
yield* ctx.command.transform((draft) => draft.add({ name: "greet", execute: () => Effect.void }))
}),
},
])
expect(yield* commands.get("greet")).toBeDefined()
expect(cleaned).toBe(false)
"1",
),
])
yield* plugins.activate([])
expect(seen).toEqual([
new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
])
}),
)
expect(yield* commands.get("greet")).toBeUndefined()
expect(cleaned).toBe(true)
}),
)
it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const bus = yield* Bus.Service
const host = yield* PluginHost.make(plugins)
const received = yield* host.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("10 millis")
it.effect("reports a failed plugin without blocking a healthy plugin", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
yield* plugins.activate([
{ id: "broken", revision: "1", effect: () => Effect.die(new Error("Setup failed")) },
{
id: "greeting",
revision: "1",
yield* bus.publish(ConfigSchema.Event.Updated, {})
expect((yield* Fiber.join(received)).valueOrUndefined?.type).toBe("config.updated")
}),
)
it.effect("exposes MCP reads and transforms and routes explicit read locations", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const runtime = yield* PluginRuntime.Service
const target = AbsolutePath.make("/target")
const routed: string[] = []
const host = yield* PluginHost.make(plugins).pipe(
Effect.provideService(
PluginRuntime.Service,
PluginRuntime.Service.of({
...runtime,
location: {
agent: runtime.location.agent,
mcp: {
list: (ref) =>
Effect.sync(() => {
routed.push(`list:${ref.directory}`)
return {
location: new Location.Info({
directory: ref.directory,
project: {
id: Project.ID.make("project"),
directory: ref.directory,
canonical: ref.directory,
},
}),
data: [],
}
}),
},
},
}),
),
)
const location = { directory: target }
expect(Object.keys(host.mcp).sort()).toEqual(["list", "reload", "transform"])
expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target)
expect(routed).toEqual(["list:/target"])
}),
)
it.effect("forwards session interrupt options through the runtime cell", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const fallback = yield* PluginRuntime.Service
const cell = PluginRuntime.makeCell()
const sessionID = Session.ID.create()
const calls: Array<{ sessionID: Session.ID; options: { continue?: boolean } | undefined }> = []
cell.runtime = {
...fallback,
session: {
...fallback.session,
interrupt: (id, options) =>
Effect.sync(() => {
calls.push({ sessionID: id, options })
return true
}),
},
}
const runtime = yield* PluginRuntime.Service.pipe(Effect.provide(PluginRuntime.layerWithCell(cell)))
const host = yield* PluginHost.make(plugins).pipe(Effect.provideService(PluginRuntime.Service, runtime))
expect(yield* runtime.session.interrupt(sessionID)).toBe(true)
expect(yield* host.session.interrupt({ sessionID, continue: true })).toEqual({ interrupted: true })
expect(calls).toEqual([
{ sessionID, options: undefined },
{ sessionID, options: { continue: true } },
])
}),
)
it.effect("registers and removes scoped VCS providers", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const vcs = yield* Vcs.Service
const provider = EffectPlugin.define({
id: "custom-vcs",
effect: (ctx) =>
ctx.command
.transform((draft) => draft.add({ name: "greet", execute: () => Effect.void }))
ctx.vcs
.transform((draft) => {
draft.add({
id: "custom",
name: "Custom VCS",
info: () => Effect.succeed({ branch: { current: "feature" } }),
branches: () => Effect.succeed(["feature"]),
status: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
})
draft.default.set("custom")
})
.pipe(Effect.asVoid),
},
])
})
expect((yield* plugins.list()).find((plugin) => plugin.id === "broken")?.state).toMatchObject({
status: "failed",
error: expect.stringContaining("Setup failed"),
})
expect(yield* commands.get("greet")).toBeDefined()
}),
)
yield* plugins.activate([generation(provider)])
expect(yield* vcs.info()).toEqual({ branch: { current: "feature" } })
expect(yield* vcs.branches()).toEqual(["feature"])
it.effect("reloading a plugin replaces its command implementation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const output: string[] = []
const load = (revision: string, text: string) =>
plugins.activate([
{
id: "greeting",
revision,
yield* plugins.activate([])
expect(yield* vcs.info()).toEqual({ branch: {} })
}),
)
it.effect("replaces plugins by ID and revision", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
const bus = yield* Bus.Service
let description = "first"
let updates = 0
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type === Plugin.Event.Updated.type) updates++
}),
)
const managed = () =>
EffectPlugin.define({
id: "managed",
effect: (ctx) =>
ctx.command
.transform((draft) =>
draft.add({
name: "greet",
execute: () =>
Effect.sync(() => {
output.push(text)
}),
ctx.agent
.transform((agents) =>
agents.update("configured", (agent) => {
agent.description = description
}),
)
.pipe(Effect.asVoid),
})
yield* plugins.activate([generation(managed(), "1")])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("first")
description = "second"
yield* plugins.activate([generation(managed(), "2")])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
description = "third"
yield* plugins.activate([generation(managed(), "2")])
expect(updates).toBe(2)
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
yield* plugins.activate(
[generation(managed(), "2")],
[
{
source: { type: "package", target: "broken" },
state: { status: "failed", error: "failed to resolve" },
features: { server: true },
},
],
)
expect(updates).toBe(3)
yield* plugins.activate([])
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
expect(updates).toBe(4)
yield* unsubscribe
}),
)
it.effect("emits rebuilt state when disabling one plugin while another remains enabled", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
const bus = yield* Bus.Service
const definitions = ["first", "second"].map((id) =>
generation(
EffectPlugin.define({
id,
effect: (ctx) => ctx.agent.transform((draft) => draft.update(id, () => {})),
}),
),
)
yield* plugins.activate(definitions)
const observed: string[][] = []
const unsubscribe = yield* bus.listen((event) =>
event.type === Agent.Event.Updated.type
? agents.list().pipe(
Effect.flatMap((items) => Effect.sync(() => observed.push(items.map((item) => item.id)))),
Effect.asVoid,
)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
yield* plugins.activate(definitions.slice(1))
expect(yield* agents.get(Agent.ID.make("first"))).toBeUndefined()
expect(yield* agents.get(Agent.ID.make("second"))).toBeDefined()
expect(observed).toEqual([["second"]])
}),
)
it.effect("updates inventory metadata without restarting an unchanged generation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
let loads = 0
const plugin = {
id: "metadata",
revision: "1",
source: { type: "package" as const, target: "fixture" },
effect: () => Effect.sync(() => loads++),
}
yield* plugins.activate([plugin])
yield* plugins.activate([{ ...plugin, source: { ...plugin.source, outdated: true } }])
expect(loads).toBe(1)
expect((yield* plugins.list())[0]?.source).toEqual({ type: "package", target: "fixture", outdated: true })
}),
)
it.effect("rejects duplicate IDs before replacing active plugins", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const active = Plugin.ID.make("active")
const duplicate = "duplicate"
yield* plugins.activate([{ id: active, revision: "1", effect: () => Effect.void }])
const result = yield* plugins
.activate([
{ id: duplicate, revision: "1", effect: () => Effect.void },
{ id: duplicate, revision: "1", effect: () => Effect.void },
])
.pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
expect(yield* plugins.list()).toEqual([
{ id: active, source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
])
}),
)
it.effect("reports activated and discovered plugin features", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.activate([
{ id: "rpc-plugin", revision: "1", features: { rpc: true }, effect: () => Effect.void },
])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("rpc-plugin"),
source: { type: "builtin" },
state: { status: "active" },
features: { server: true, rpc: true },
},
])
const request = {
name: "greet",
invocation: { sessionID: Session.ID.make("ses_plugin"), prompt: { text: "" }, delivery: "steer" as const },
}
}),
)
yield* load("1", "before")
yield* commands.execute(request)
expect(output).toEqual(["before"])
it.effect("skips failed plugins and loads the rest", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
let fail = true
const good = EffectPlugin.define({
id: "good",
effect: (ctx) =>
ctx.agent
.transform((agents) =>
agents.update("configured", (agent) => {
agent.description = "loaded"
}),
)
.pipe(Effect.asVoid),
})
const bad = EffectPlugin.define({
id: "bad",
effect: () => {
if (fail) return Effect.die(new Error("materialization failed"))
return Effect.void
},
})
yield* load("2", "after")
yield* commands.execute(request)
expect(output).toEqual(["before", "after"])
}),
)
yield* plugins.activate([generation(good), generation(bad)])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("good"),
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
{
id: Plugin.ID.make("bad"),
source: { type: "builtin" },
state: { status: "failed", error: expect.stringContaining("materialization failed") },
features: { server: true },
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
fail = false
yield* plugins.activate([generation(good), generation(bad, "2")])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("good"),
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
{
id: Plugin.ID.make("bad"),
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
])
}),
)
it.effect("keeps plugins active when a tool registration is invalid", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const tools = yield* Tool.Service
const agents = yield* Agent.Service
yield* plugins.activate([
{
id: "partial-tools",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool.transform((draft) => {
const tool = {
name: "healthy",
description: "Healthy tool",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
}
draft.add({ ...tool, name: "invalid", options: { namespace: "invalid..namespace" } })
draft.add(tool)
})
yield* ctx.agent.transform((draft) =>
draft.update("configured", (agent) => {
agent.description = "setup continued"
}),
)
}),
},
])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("partial-tools"),
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("setup continued")
expect((yield* tools.snapshot()).definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
yield* plugins.activate([])
expect((yield* tools.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
it.effect("restores the previous plugin when its replacement fails", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
const previous = EffectPlugin.define({
id: "managed",
effect: (ctx) =>
ctx.agent
.transform((agents) =>
agents.update("configured", (agent) => {
agent.description = "previous"
}),
)
.pipe(Effect.asVoid),
})
const replacement = EffectPlugin.define({
id: "managed",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.agent.transform((agents) =>
agents.update("configured", (agent) => {
agent.description = "replacement"
}),
)
return yield* Effect.die(new Error("replacement failed"))
}),
})
yield* plugins.activate([generation(previous)])
yield* plugins.activate([generation(replacement, "2")])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
state: { status: "failed", error: expect.stringContaining("replacement failed") },
features: { server: true },
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
}),
)
it.effect("deactivates a plugin when replacement and restoration fail", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const agents = yield* Agent.Service
let loads = 0
const previous = EffectPlugin.define({
id: "managed",
effect: (ctx) => {
loads++
if (loads > 1) return Effect.die(new Error("restoration failed"))
return ctx.agent
.transform((agents) =>
agents.update("configured", (agent) => {
agent.description = "previous"
}),
)
.pipe(Effect.asVoid)
},
})
const replacement = EffectPlugin.define({
id: "managed",
effect: () => Effect.die(new Error("replacement failed")),
})
yield* plugins.activate([generation(previous)])
yield* plugins.activate([generation(replacement, "2")])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
state: { status: "failed", error: expect.stringContaining("replacement failed") },
features: { server: true },
},
])
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
}),
)
it.effect("closes the previous generation in reverse order", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const closed: string[] = []
yield* plugins.activate(
["first", "second"].map((id) => ({
id,
revision: "1",
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* Plugin.Service
let visible = true
const plugin = EffectPlugin.define({
id: "isolated",
effect: () =>
Effect.serviceOption(Secret).pipe(
Effect.tap((secret) => Effect.sync(() => (visible = secret._tag === "Some"))),
Effect.asVoid,
),
})
yield* plugins.activate([generation(plugin)]).pipe(Effect.provideService(Secret, "secret"))
expect(visible).toBe(false)
}),
)
it.effect("provides isolated durable storage for each plugin ID", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const storage = new Map<string, EffectPlugin.Context["storage"]>()
yield* plugins.activate(
["a", "a:b", "雪"].map((id) => ({
id,
revision: "1",
effect: (context: EffectPlugin.Context) => Effect.sync(() => storage.set(id, context.storage)),
})),
)
const first = storage.get("a")
const second = storage.get("a:b")
const unicode = storage.get("雪")
if (!first || !second || !unicode) return yield* Effect.die("plugin storage was not activated")
yield* first.set("b:c", { plugin: "a" })
yield* second.set("c", { plugin: "a:b" })
yield* unicode.set("c", { plugin: "雪" })
expect(yield* first.get("b:c")).toEqual({ plugin: "a" })
expect(yield* second.get("c")).toEqual({ plugin: "a:b" })
expect(yield* unicode.get("c")).toEqual({ plugin: "雪" })
expect(yield* first.get("c")).toBeUndefined()
const prefix = "%_:/雪/"
yield* first.set(`${prefix}beta`, [2])
yield* first.set(`${prefix}alpha`, [1])
const firstPage = yield* first.scan({ prefix, limit: 1 })
expect(firstPage).toEqual({ entries: [{ key: `${prefix}alpha`, value: [1] }], next: `${prefix}alpha` })
expect(yield* first.scan({ prefix, after: firstPage.next, limit: 1 })).toEqual({
entries: [{ key: `${prefix}beta`, value: [2] }],
})
expect(yield* first.scan({ prefix: `${prefix}%_` })).toEqual({ entries: [] })
expect(yield* first.scan({ prefix: "" })).toEqual({
entries: [
{ key: `${prefix}alpha`, value: [1] },
{ key: `${prefix}beta`, value: [2] },
{ key: "b:c", value: { plugin: "a" } },
],
})
yield* first.remove("b:c")
yield* first.remove("b:c")
expect(yield* first.get("b:c")).toBeUndefined()
return undefined
}),
)
it.effect("registers location tools through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const plugin = EffectPlugin.define({
id: "tool-plugin",
effect: (ctx) =>
ctx.tool
.transform((draft) =>
draft.add({
name: "plugin_tool",
options: { codemode: false },
description: "Plugin tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ output: { ok: true } }),
}),
)
.pipe(Effect.orDie),
})
yield* plugins.activate([generation(plugin)])
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
yield* plugins.activate([])
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).not.toContain("plugin_tool")
}),
)
it.effect("namespaces tool names and routes codemode registrations through execute", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const tool = (name: string, description: string, options?: Tool.Options) => ({
name,
options,
description,
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ output: { ok: true } }),
})
const plugin = EffectPlugin.define({
id: "grouped-tools",
effect: (ctx) =>
ctx.tool
.transform((draft) => {
draft.add(tool("plain", "Plain", { codemode: false }))
draft.add(tool("look/up", "Lookup", { namespace: "context7", codemode: false }))
draft.add(tool("search", "Search", { namespace: "context7" }))
})
.pipe(Effect.orDie),
})
yield* plugins.activate([generation(plugin)])
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toEqual([
"context7_look_up",
"plain",
"execute",
])
}),
)
it.effect("fires before/after tool hooks with mutable events around execution", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
const seen: {
before?: { input: unknown; tool: string }
after?: { input: unknown; status: string; content: unknown; metadata: unknown }
} = {}
const plugin = EffectPlugin.define({
id: "tool-hooks",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool
.transform((draft) =>
draft.add({
name: "echo",
options: { codemode: false },
description: "Echo",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) =>
Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })),
}),
)
.pipe(Effect.orDie)
yield* ctx.tool
.hook("execute.before", (event) =>
Effect.sync(() => {
expect(event).not.toHaveProperty("inputSchema")
seen.before = { input: event.input, tool: event.tool }
event.tool = "echo"
event.input = { text: "before-mutated" }
}),
)
.pipe(Effect.asVoid)
yield* ctx.tool
.hook("execute.after", (event) =>
Effect.sync(() => {
seen.after = {
input: event.input,
status: event.status,
content: event.status === "completed" ? event.result.content : undefined,
metadata: event.status === "completed" ? event.result.metadata : event.error.metadata,
}
if (event.status !== "completed") return
event.result = {
...event.result,
content: [{ type: "text", text: "after-mutated" }],
metadata: { rewritten: true },
}
}),
)
.pipe(Effect.asVoid)
yield* ctx.tool
.hook("execute.after", (event) =>
Effect.sync(() => {
if (event.status === "completed" && Array.isArray(event.result.content))
event.result.content.splice(0)
}),
)
.pipe(Effect.asVoid)
}),
})
yield* plugins.activate([generation(plugin)])
const toolSet = yield* registry.snapshot()
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_hooks"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_hooks"),
call: { type: "tool-call", id: "call-hooks", name: "misspelled", input: { text: "original" } },
})
expect(seen.before).toEqual({
input: { text: "original" },
tool: "misspelled",
})
expect(executed).toEqual([{ text: "before-mutated" }])
expect(seen.after).toEqual({
input: { text: "before-mutated" },
status: "completed",
content: [{ type: "text", text: '{"text":"before-mutated"}' }],
metadata: undefined,
})
expect(execution).toMatchObject({
content: [{ type: "text", text: '{"text":"before-mutated"}' }],
metadata: { rewritten: true },
})
}),
)
it.effect("rejects tool execution when an execute.before hook fails", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
const plugin = EffectPlugin.define({
id: "tool-hook-reject",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool
.transform((draft) =>
draft.add({
name: "echo",
options: { codemode: false },
description: "Echo",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) =>
Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })),
}),
)
.pipe(Effect.orDie)
yield* ctx.tool
.hook("execute.before", () => new ToolFailure({ message: "write disabled" }))
.pipe(Effect.asVoid)
}),
})
yield* plugins.activate([generation(plugin)])
const toolSet = yield* registry.snapshot()
const failure = yield* toolSet
.execute({
sessionID: Session.ID.make("ses_hook_reject"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_hook_reject"),
call: { type: "tool-call", id: "call-hook-reject", name: "missing", input: { text: "original" } },
})
.pipe(Effect.flip)
expect(failure).toMatchObject({ _tag: "Tool.Error", message: "write disabled" })
expect(executed).toEqual([])
}),
)
})
+1 -1
View File
@@ -100,4 +100,4 @@ export const PluginTestLayer = LayerNode.compile(
Permission.node.replace(permissionLayer),
],
},
)
) as unknown as Layer.Layer<unknown, never>
@@ -0,0 +1,15 @@
import { Plugin } from "@opencode-ai/plugin/effect"
import { Effect } from "effect"
export default Plugin.define({
id: "config-effect-plugin",
effect: (ctx) =>
ctx.agent
.transform((agents) => {
agents.update("effect-configured", (agent) => {
agent.description = ctx.options.description
agent.mode = "subagent"
})
})
.pipe(Effect.asVoid),
})
@@ -0,0 +1 @@
export { default } from "../config-effect-plugin"
@@ -0,0 +1,13 @@
import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "config-promise-plugin",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("configured", (agent) => {
agent.description = ctx.options.description
agent.mode = "subagent"
})
})
},
})
@@ -0,0 +1 @@
export { default } from "../config-promise-plugin"
@@ -0,0 +1 @@
export default { id: "config-promise-plugin.tui", setup() {} }
@@ -0,0 +1,7 @@
import { Plugin } from "@opencode-ai/plugin/effect"
import { Effect } from "effect"
export default Plugin.define({
id: "failing-plugin",
effect: () => Effect.die("plugin failed"),
})
@@ -0,0 +1 @@
export { default } from "../failing-plugin"
@@ -1,14 +0,0 @@
import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "greeting",
async setup(ctx) {
await ctx.command.transform((draft) =>
draft.add({
name: "greet",
description: ctx.options.description,
execute: async () => {},
}),
)
},
})
@@ -0,0 +1 @@
export default {}
@@ -0,0 +1 @@
export { default } from "../invalid-plugin"
@@ -0,0 +1 @@
throw new Error("private plugin loader details")
@@ -0,0 +1,28 @@
import { Plugin } from "@opencode-ai/plugin/effect"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { Effect } from "effect"
export default Plugin.define({
id: "variant-source",
effect: (ctx) =>
ctx.catalog
.transform((catalog) => {
catalog.provider.update("configured", (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
})
catalog.model.update("configured", "glm-5.2", (model) => {
model.modelID = Model.ID.make("glm-5.2")
model.package = Provider.aisdk("@ai-sdk/openai-compatible")
model.variants = [
{
id: Model.VariantID.make("high"),
settings: {},
headers: { custom: "true" },
body: {},
},
]
})
})
.pipe(Effect.asVoid),
})
@@ -0,0 +1 @@
export { default } from "../variant-source-plugin"
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { Session } from "@opencode-ai/schema/session"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tempGlobalLayer } from "../fixture/global"
import { testEffect } from "../lib/effect"
const cell = PluginRuntime.makeCell()
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Global.node,
Bus.node,
PersistentPty.node,
PluginRuntime.node,
PluginRuntime.providerNodeWithCell(cell),
]),
[
Global.node.replace(tempGlobalLayer),
Watcher.node.replace(Watcher.configured({ enabled: false })),
SessionExecution.node.replace(SessionExecution.noopLayer),
PluginRuntime.node.replace(PluginRuntime.layerWithCell(cell)),
PersistentPty.node.replace(PersistentPty.configured()),
],
),
)
describe("Plugin runtime terminal reads", () => {
it.live("shares the configured global PTY service and validates lines before an empty selection", () =>
Effect.gen(function* () {
const runtime = yield* PluginRuntime.Service
const persistentPty = yield* PersistentPty.Service
const sessionID = Session.ID.make("ses_no_terminal")
expect(cell.runtime?.persistentPty).toBe(persistentPty)
expect(yield* runtime.persistentPty.read(sessionID)).toBeNull()
expect(yield* runtime.persistentPty.read(sessionID, 1)).toBeNull()
expect(yield* runtime.persistentPty.read(sessionID, 65535)).toBeNull()
yield* Effect.forEach([0, -1, 1.5, 65536, NaN, Infinity], (lines) =>
Effect.gen(function* () {
const error = yield* runtime.persistentPty.read(sessionID, lines).pipe(Effect.flip)
expect(error).toBeInstanceOf(PersistentPty.UnavailableError)
expect(error.message).toContain("lines")
}),
)
}),
)
})
+37
View File
@@ -0,0 +1,37 @@
import path from "path"
import { pathToFileURL } from "url"
import { expect, test } from "bun:test"
import { PluginModule } from "@opencode-ai/core/plugin/module"
import { Npm } from "@opencode-ai/util/npm"
import { Effect } from "effect"
test("loads cached plugin packages without requesting a refresh", async () => {
const calls: unknown[] = []
const entrypoint = path.join(import.meta.dir, "fixtures", "config-effect-plugin.ts")
const plugin = await PluginModule.load({ type: "add", target: "fixture-plugin", options: {} }).pipe(
Effect.provideService(
Npm.Service,
Npm.Service.of({
add: (_pkg, options) =>
Effect.sync(() => {
calls.push(options)
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href, version: "1.2.3" }
}),
resolve: (_pkg, options) =>
Effect.sync(() => {
calls.push(options)
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
}),
check: () => Effect.die(new Error("Unexpected check")),
update: () => Effect.die(new Error("Unexpected update")),
which: () => Effect.die(new Error("Unexpected which")),
}),
),
Effect.runPromise,
)
expect(plugin.id).toBe("config-effect-plugin")
expect(plugin.features).toEqual({ tui: true, rpc: true })
expect(plugin.source).toEqual({ type: "package", target: "fixture-plugin", version: "1.2.3" })
expect(calls).toEqual([{ subpaths: ["server", ""] }, { subpaths: ["tui"] }, { subpaths: ["rpc"] }])
})
File diff suppressed because it is too large Load Diff
+34 -207
View File
@@ -14,7 +14,6 @@ import { Provider } from "@opencode-ai/core/provider"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { Session } from "@opencode-ai/core/session"
import { State } from "@opencode-ai/core/state"
import { AppProcess } from "@opencode-ai/util/process"
import { fakeSelectorSdk } from "../fixture/selector"
import { testEffect } from "../lib/effect"
@@ -54,12 +53,7 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Ef
)
}
function withAzureCommands<A, E, R>(
run: (args: readonly string[]) => unknown,
fx: () => Effect.Effect<A, E, R>,
deploymentDelay = 0,
signedIn = true,
) {
function withAzureCommands<A, E, R>(run: (args: readonly string[]) => unknown, fx: () => Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const processes = yield* AppProcess.Service
const directory = (yield* Location.Service).directory
@@ -68,9 +62,6 @@ function withAzureCommands<A, E, R>(
Bun.write(executable, process.platform === "win32" ? "@exit /b 0\r\n" : "#!/bin/sh\nexit 0\n"),
)
yield* Effect.promise(() => chmod(executable, 0o755))
if (signedIn) {
yield* Effect.promise(() => Bun.write(`${directory}/azure-cli/azureProfile.json`, '{"subscriptions":[{}]}'))
}
const fake = AppProcess.Service.of({
...processes,
run: (command) => {
@@ -79,7 +70,7 @@ function withAzureCommands<A, E, R>(
if (value instanceof Error) {
return Effect.fail(new AppProcess.AppProcessError({ command: "az", cause: value }))
}
const result = Effect.succeed({
return Effect.succeed({
command: `az ${command.args.join(" ")}`,
exitCode: 0,
stdout: Buffer.from(JSON.stringify(value)),
@@ -87,16 +78,11 @@ function withAzureCommands<A, E, R>(
stdoutTruncated: false,
stderrTruncated: false,
})
if (deploymentDelay > 0 && command.args.includes("deployment")) {
return Effect.sleep(`${deploymentDelay} millis`).pipe(Effect.andThen(result))
}
return result
},
})
return yield* withEnv(
{
PATH: `${directory}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}`,
AZURE_CONFIG_DIR: `${directory}/azure-cli`,
},
() => fx().pipe(Effect.provideService(AppProcess.Service, fake)),
)
@@ -177,13 +163,12 @@ describe("AzurePlugin", () => {
),
)
it.live("does not invoke Azure CLI at startup without a cached Azure login", () => {
it.live("does not invoke Azure CLI at startup without an Azure connection", () => {
const commands: string[] = []
return withEnv(
{
AZURE_RESOURCE_NAME: undefined,
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined,
AZURE_RESOURCE_GROUP: undefined,
},
() =>
withAzureCommands(
@@ -198,48 +183,20 @@ describe("AzurePlugin", () => {
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure"))
expect(integration?.methods.some((method) => method.type === "oauth")).toBe(true)
}),
0,
false,
),
)
})
it.live("lists Azure CLI resources and keeps manual resource entry available", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
withAzureCommands(
() => [
{ name: "first-resource", resourceGroup: "first-group" },
{ name: "second-resource", resourceGroup: "second-group" },
],
() =>
Effect.gen(function* () {
yield* addPlugin()
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure"))
const method = integration?.methods.find((item) => item.type === "oauth")
expect(method?.form?.[0]).toMatchObject({
title: "Enter Azure Resource Name",
options: [
{ value: "first-resource", label: "first-resource", description: "first-group" },
{ value: "second-resource", label: "second-resource", description: "second-group" },
],
custom: true,
})
}),
),
),
)
it.live("connects with the Azure CLI and accepts legacy token expiration", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
it.live("connects with the Azure CLI and accepts legacy token expiration", () => {
const commands: string[][] = []
return withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
withAzureCommands(
(args) => {
if (args.includes("get-access-token")) {
return {
accessToken: "legacy-cli-token",
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
}
commands.push([...args])
return {
accessToken: "legacy-cli-token",
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
}
return []
},
() =>
Effect.gen(function* () {
@@ -262,130 +219,28 @@ describe("AzurePlugin", () => {
access: "legacy-cli-token",
metadata: { resourceName: "test-resource" },
})
expect(commands).toEqual([
[
"account",
"get-access-token",
"--scope",
"https://cognitiveservices.azure.com/.default",
"--output",
"json",
],
])
}),
),
),
)
it.live("finishes deployment discovery before Azure authorization completes", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
withAzureCommands(
(args) => {
if (args.includes("get-access-token")) {
return {
accessToken: "azure-token",
expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
}
}
if (args.includes("deployment")) {
return [
{
name: "gpt-production",
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
},
]
}
return [{ name: "test-resource", resourceGroup: "test-group" }]
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.azure, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/azure")
})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {})
})
yield* addPlugin()
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make("azure")
const attempt = yield* integrations.oauth.connect({
integrationID,
methodID: Integration.MethodID.make("azure-cli"),
answer: { resourceName: "test-resource" },
})
yield* Effect.gen(function* () {
const status = yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })
if (status.status !== "complete") {
return yield* Effect.fail(
new Error(
`Azure CLI authorization ${status.status}${"message" in status ? `: ${status.message}` : ""}`,
),
)
}
}).pipe(Effect.retry({ times: 1500, schedule: Schedule.spaced("1 millis") }))
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeUndefined()
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe(
Model.ID.make("gpt-production"),
)
}),
50,
),
),
)
for (const batched of [false, true]) {
it.effect(
`discovers deployments with an existing connection (${batched ? "batched startup" : "ready catalog"})`,
() =>
withEnv({ AZURE_RESOURCE_GROUP: undefined }, () =>
withAzureCommands(
(args) => {
if (args.includes("deployment")) {
return [
{
name: "gpt-production",
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
},
{
name: "gpt-staging",
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
},
{
name: "gpt-pending",
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Creating" },
},
]
}
return [{ name: "test-resource", resourceGroup: "test-group" }]
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* azureCredential
const startup = Effect.gen(function* () {
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.azure, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/azure")
})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), (model) => {
model.name = "GPT-5 Mini"
})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {})
})
yield* addPlugin()
})
yield* batched ? State.batch(startup) : startup
expect((yield* catalog.provider.get(Provider.ID.azure))?.settings?.resourceName).toBe("test-resource")
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe(
Model.ID.make("gpt-production"),
)
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-staging")))?.modelID).toBe(
Model.ID.make("gpt-staging"),
)
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeUndefined()
}),
),
),
)
}
})
it.effect("keeps existing models when Azure deployment discovery is unavailable", () =>
withAzureCommands(
() => new Error("management access denied"),
it.live("does not invoke Azure CLI at startup with an existing connection", () => {
const commands: string[][] = []
return withAzureCommands(
(args) => {
commands.push([...args])
return []
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -394,46 +249,18 @@ describe("AzurePlugin", () => {
provider.package = Provider.aisdk("@ai-sdk/azure")
})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {})
})
yield* azureCredential
yield* addPlugin()
expect(commands).toEqual([])
expect((yield* catalog.provider.get(Provider.ID.azure))?.settings?.resourceName).toBe("test-resource")
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini"))).toBeDefined()
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeDefined()
}),
),
)
it.effect("skips subscription discovery when the resource group is configured", () =>
withEnv({ AZURE_RESOURCE_GROUP: "restricted-group" }, () =>
withAzureCommands(
(args) => {
expect(args).toContain("deployment")
expect(args).toContain("restricted-group")
return [
{
name: "gpt-production",
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
},
]
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.azure, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/azure")
})
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
})
yield* azureCredential
yield* addPlugin()
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe(
Model.ID.make("gpt-production"),
)
}),
),
),
)
)
})
it.effect("uses the correct bearer token audience for Azure and Foundry requests", () =>
withAzureCommands(
@@ -0,0 +1,106 @@
import { expect } from "bun:test"
import { Plugin } from "@opencode-ai/core/plugin"
import { Rpc } from "@opencode-ai/core/rpc"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { PluginTestLayer } from "./fixture"
import { Effect, Exit, Schema } from "effect"
import { testEffect } from "../lib/effect"
const it = testEffect(PluginTestLayer)
const Echo = Rpc.define({
id: "shared-echo",
methods: {
echo: { input: Schema.String, output: Schema.String },
fail: {
input: Schema.String,
output: Schema.String,
errors: { missing: Schema.Struct({ attempts: Schema.FiniteFromString }) },
},
},
events: { updated: { schema: Schema.Struct({ text: Schema.String }) } },
})
it.effect("Effect plugins register, call, and publish RPCs independently of plugin identity", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const rpc = yield* Rpc.Service
const bus = yield* Bus.Service
const location = yield* Location.Service
const events: string[] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type !== "rpc.shared-echo.updated") return
expect(event.location).toEqual({ directory: location.directory })
if (typeof event.data === "object" && event.data && "text" in event.data && typeof event.data.text === "string")
events.push(event.data.text)
}),
)
yield* plugins.activate([
{
id: "implementer",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
const registration = yield* ctx.rpc.register(Echo, {
echo: (value) => Effect.succeed(`${value}!`),
fail: (value, context) => Effect.fail(context.error("missing", "Missing", { attempts: Number(value) })),
})
yield* registration.events.emit("updated", { text: "ready" })
}).pipe(Effect.orDie),
},
{
id: "consumer",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
expect(yield* ctx.rpc(Echo).echo("hello")).toBe("hello!")
expect(yield* ctx.rpc(Echo).fail("2").pipe(Effect.flip)).toEqual({
type: "missing",
message: "Missing",
data: { attempts: 2 },
})
}).pipe(Effect.orDie),
},
])
expect(events).toEqual(["ready"])
expect(yield* rpc.client(Echo).echo("hello")).toBe("hello!")
yield* plugins.activate([])
expect(Exit.isFailure(yield* rpc.client(Echo).echo("hello").pipe(Effect.exit))).toBe(true)
yield* unsubscribe
}),
)
it.effect("failed plugin setup removes RPC overrides and restores the previous implementation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const rpc = yield* Rpc.Service
yield* plugins.activate([
{
id: "implementer",
revision: "1",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
echo: () => Effect.succeed("original"),
fail: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: 1 })),
})
.pipe(Effect.asVoid, Effect.orDie),
},
])
yield* plugins.activate([
{
id: "implementer",
revision: "2",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
echo: () => Effect.succeed("replacement"),
fail: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: 1 })),
})
.pipe(Effect.andThen(Effect.die(new Error("setup failed"))), Effect.orDie),
},
])
expect(yield* rpc.client(Echo).echo("hello")).toBe("original")
}),
)
@@ -0,0 +1,291 @@
import { describe, expect } from "bun:test"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { define } from "@opencode-ai/plugin/promise/plugin"
import type { RpcEventPayload } from "@opencode-ai/plugin/promise/rpc"
import { Rpc } from "@opencode-ai/plugin/rpc"
import { Effect, Logger } from "effect"
import { z } from "zod"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
describe("Promise plugin RPC", () => {
it.live("adapts calls, schema transforms, failures, and registration disposal", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
id: "promise-rpc-calls",
methods: {
standard: { input: z.string().transform(Number), output: z.number().transform(String) },
ping: { input: z.undefined(), output: z.null() },
errorShapedOutput: {
input: z.undefined(),
output: z.object({ type: z.string(), message: z.string(), data: z.object({ value: z.number() }) }),
},
returned: {
input: z.undefined(),
output: z.null(),
errors: { rejected: z.object({ attempts: z.string().transform(Number) }) },
},
thrown: {
input: z.undefined(),
output: z.null(),
errors: { rejected: z.object({ attempts: z.string().transform(Number) }) },
},
defect: { input: z.undefined(), output: z.null() },
},
events: {},
})
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-calls-plugin",
setup: async (ctx) => {
const registration = await ctx.rpc.register(service, {
standard: async (input) => {
expect(input).toBe(42)
return input + 1
},
ping: async () => null,
errorShapedOutput: async () => ({ type: "ordinary", message: "Success", data: { value: 1 } }),
returned: async (_input, context) =>
context.error("rejected", "returned failure", { attempts: "1" }),
thrown: async (_input, context) => {
throw context.error("rejected", "thrown failure", { attempts: "2" })
},
defect: async () => {
throw new Error("handler defect")
},
})
const client = ctx.rpc(service)
expect(await client.standard("42")).toBe("43")
expect(await client.ping()).toBeNull()
expect(await client.errorShapedOutput()).toEqual({
type: "ordinary",
message: "Success",
data: { value: 1 },
})
await expect(client.returned()).rejects.toEqual({
type: "rejected",
message: "returned failure",
data: { attempts: 1 },
})
await expect(client.thrown()).rejects.toEqual({
type: "rejected",
message: "thrown failure",
data: { attempts: 2 },
})
await expect(client.defect()).rejects.toThrow("handler defect")
await registration.dispose()
await registration.dispose()
await expect(client.ping()).rejects.toBeDefined()
},
}),
)
yield* plugins.activate([{ ...adapted, revision: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
}),
)
it.live("cancels only the selected call and passes its AbortSignal to Promise handlers", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
id: "promise-rpc-cancel",
methods: { wait: { input: z.string(), output: z.string() } },
events: {},
})
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-cancel-plugin",
setup: async (ctx) => {
const started = Promise.withResolvers<void>()
const cancelled = Promise.withResolvers<void>()
const signals = new Map<string, AbortSignal>()
await ctx.rpc.register(service, {
wait: async (input, call) => {
signals.set(input, call.signal)
if (input === "complete") return input
started.resolve()
await new Promise<void>((resolve) => {
call.signal.addEventListener(
"abort",
() => {
cancelled.resolve()
resolve()
},
{ once: true },
)
})
return input
},
})
const client = ctx.rpc(service)
const controller = new AbortController()
const pending = client.wait("cancel", { signal: controller.signal })
const rejected = pending.then(
() => false,
() => true,
)
await started.promise
expect(await client.wait("complete")).toBe("complete")
controller.abort()
expect(await rejected).toBe(true)
await cancelled.promise
expect(signals.get("cancel")?.aborted).toBe(true)
expect(signals.get("complete")?.aborted).toBe(false)
expect(await client.wait("complete")).toBe("complete")
},
}),
)
yield* plugins.activate([{ ...adapted, revision: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
}),
)
it.live("awaits async callbacks and logs failures without stopping other plugin listeners", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
id: "promise-rpc-async-listeners",
methods: {},
events: { updated: { schema: z.object({ value: z.number() }) } },
})
const error = new Error("Expected async plugin callback failure")
const reported = Promise.withResolvers<void>()
const logger = Logger.make((entry) => {
if (Array.isArray(entry.message) && entry.message.includes(error)) reported.resolve()
})
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-async-listeners-plugin",
setup: async (ctx) => {
const registration = await ctx.rpc.register(service, {})
const client = ctx.rpc(service)
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const third = Promise.withResolvers<void>()
const failed: number[] = []
const healthy: number[] = []
client.events.on("updated", async (event) => {
failed.push(event.data.value)
started.resolve()
await release.promise
throw error
})
client.events.on("updated", (event) => {
healthy.push(event.data.value)
if (event.data.value === 2) second.resolve()
if (event.data.value === 3) third.resolve()
})
await registration.events.emit("updated", { value: 1 })
await started.promise
await registration.events.emit("updated", { value: 2 })
await second.promise
expect(failed).toEqual([1])
release.resolve()
await reported.promise
await registration.events.emit("updated", { value: 3 })
await third.promise
expect(failed).toEqual([1])
expect(healthy).toEqual([1, 2, 3])
},
}),
)
yield* plugins
.activate([{ ...adapted, revision: "1" }])
.pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger])))
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
yield* plugins.activate([])
}),
)
it.live("isolates event listeners and closes pending and idle iterators on plugin unload", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
id: "promise-rpc-events",
methods: {},
events: {
counted: { schema: z.object({ count: z.number() }).transform(({ count }) => ({ text: String(count) })) },
},
})
const subscriptions = Promise.withResolvers<{
pending: Promise<IteratorResult<RpcEventPayload<typeof service, "counted">>>
idle: AsyncIterator<RpcEventPayload<typeof service, "counted">>
nativeIdle: AsyncIterator<unknown>
}>()
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-events-plugin",
setup: async (ctx) => {
const registration = await ctx.rpc.register(service, {})
const client = ctx.rpc(service)
const first: string[] = []
const second: string[] = []
const firstSeen = Promise.withResolvers<void>()
const secondSeen = Promise.withResolvers<void>()
const nextSeen = Promise.withResolvers<void>()
const unsubscribe = client.events.on("counted", (event) => {
first.push(event.data.text)
firstSeen.resolve()
})
client.events.on("counted", (event) => {
second.push(event.data.text)
if (event.data.text === "1") secondSeen.resolve()
if (event.data.text === "2") nextSeen.resolve()
})
const controller = new AbortController()
const iterator = client.events.subscribe("counted", { signal: controller.signal })[Symbol.asyncIterator]()
const next = iterator.next()
const idle = client.events.subscribe("counted")[Symbol.asyncIterator]()
const idleNext = idle.next()
const nativeController = new AbortController()
const native = ctx.event.subscribe({ signal: nativeController.signal })[Symbol.asyncIterator]()
const nativeNext = native.next()
const nativeIdle = ctx.event.subscribe()[Symbol.asyncIterator]()
const nativeIdleNext = nativeIdle.next()
await registration.events.emit("counted", { count: 1 })
await Promise.all([firstSeen.promise, secondSeen.promise])
const event = (await next).value
expect(event.type).toBe("rpc.promise-rpc-events.counted")
expect(event.data).toEqual({ text: "1" })
expect(typeof event.location.directory).toBe("string")
expect((await idleNext).value.data).toEqual({ text: "1" })
expect((await nativeNext).value.type).toBe("rpc.promise-rpc-events.counted")
expect((await nativeIdleNext).value.type).toBe("rpc.promise-rpc-events.counted")
nativeController.abort()
expect((await native.next()).done).toBe(true)
unsubscribe()
unsubscribe()
controller.abort()
expect((await iterator.next()).done).toBe(true)
await registration.events.emit("counted", { count: 2 })
await nextSeen.promise
expect(first).toEqual(["1"])
expect(second).toEqual(["1", "2"])
const aborted = client.events.subscribe("counted", { signal: controller.signal })[Symbol.asyncIterator]()
expect((await aborted.next()).done).toBe(true)
subscriptions.resolve({
pending: client.events.subscribe("counted")[Symbol.asyncIterator]().next(),
idle,
nativeIdle,
})
},
}),
)
yield* plugins.activate([{ ...adapted, revision: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
const active = yield* Effect.promise(() => subscriptions.promise)
yield* plugins.activate([])
expect((yield* Effect.promise(() => active.pending)).done).toBe(true)
expect((yield* Effect.promise(() => active.idle.next())).done).toBe(true)
expect((yield* Effect.promise(() => active.nativeIdle.next())).done).toBe(true)
}),
)
})
+51
View File
@@ -0,0 +1,51 @@
import { expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { PluginUpdate } from "@opencode-ai/core/plugin/update"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Npm } from "@opencode-ai/util/npm"
import { Effect, Fiber, Layer, Option, Stream } from "effect"
import { testEffect } from "../lib/effect"
const checks: string[] = []
const npm = makeGlobalNode({
service: Npm.Service,
layer: Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.die("unused add"),
resolve: () => Effect.die("unused resolve"),
check: (target) => Effect.sync(() => checks.push(target)).pipe(Effect.as(true)),
update: () => Effect.succeed({ directory: "" }),
which: () => Effect.die("unused which"),
}),
),
deps: [],
})
const it = testEffect(AppNodeBuilder.build(PluginUpdate.node, [Npm.node.replace(npm)]))
it.effect("caches checks by target", () =>
Effect.gen(function* () {
checks.length = 0
const updates = yield* PluginUpdate.Service
const first = yield* updates.check("fixture")
const second = yield* updates.check("fixture")
expect(checks).toEqual(["fixture"])
expect(first).toBeTrue()
expect(second).toBeTrue()
}),
)
it.effect("publishes successful package updates", () =>
Effect.gen(function* () {
const updates = yield* PluginUpdate.Service
const changed = yield* updates
.changes()
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* updates.update("fixture")
expect(Option.getOrUndefined(yield* Fiber.join(changed))).toEqual({ target: "fixture", outdated: false })
}),
)
+378
View File
@@ -0,0 +1,378 @@
import { describe, expect } from "bun:test"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Rpc } from "@opencode-ai/core/rpc"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import type { Event } from "@opencode-ai/schema/event"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
import { z } from "zod"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const ref = Location.Ref.make({ directory: AbsolutePath.make("/rpc-project") })
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Rpc.node, Bus.node, Location.node]), [
Location.node.replace(Layer.succeed(Location.Service, location(ref))),
]),
)
const Echo = Rpc.define({
id: "test.rpc",
methods: { echo: { input: z.string(), output: z.string() } },
events: { updated: { schema: z.object({ text: z.string() }) } },
})
describe("Rpc", () => {
it.effect("creates handles before registration and resolves on every execution", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const client = rpc.client(Echo)
const request = client.echo("hello")
expect(yield* request.pipe(Effect.flip)).toEqual({
type: "rpc.unavailable",
message: "RPC is unavailable: test.rpc",
})
yield* rpc.register(Echo, { echo: (value) => Effect.succeed(value) })
expect(yield* request).toBe("hello")
yield* rpc.register(Echo, { echo: (value) => Effect.succeed(`${value}!`) })
expect(yield* request).toBe("hello!")
expect(yield* rpc.call(Echo.id, "missing", "hello").pipe(Effect.flip)).toEqual({
type: "rpc.method_not_found",
message: "Unknown RPC method: test.rpc.missing",
})
expect(yield* rpc.call(Echo.id, "toString", "hello").pipe(Effect.flip)).toEqual({
type: "rpc.method_not_found",
message: "Unknown RPC method: test.rpc.toString",
})
}),
)
it.effect("uses the latest whole registration and reveals previous implementations on disposal", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const client = rpc.client(Echo)
const first = yield* rpc.register(Echo, { echo: () => Effect.succeed("first") })
const second = yield* rpc.register(Echo, { echo: () => Effect.succeed("second") })
const third = yield* rpc.register(Echo, { echo: () => Effect.succeed("third") })
expect(yield* client.echo("hello")).toBe("third")
yield* second.dispose
expect(yield* client.echo("hello")).toBe("third")
yield* third.dispose
expect(yield* client.echo("hello")).toBe("first")
yield* third.dispose
expect(yield* client.echo("hello")).toBe("first")
yield* first.dispose
expect(Exit.isFailure(yield* client.echo("hello").pipe(Effect.exit))).toBe(true)
}),
)
it.effect("removes registrations when their owning scope closes", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
yield* rpc.register(Echo, { echo: () => Effect.succeed("original") })
const scope = yield* Scope.make()
yield* rpc.register(Echo, { echo: () => Effect.succeed("override") }).pipe(Scope.provide(scope))
expect(yield* rpc.client(Echo).echo("hello")).toBe("override")
yield* Scope.close(scope, Exit.void)
expect(yield* rpc.client(Echo).echo("hello")).toBe("original")
}),
)
it.effect("validates inputs before running handlers and validates returned results", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const received: string[] = []
yield* rpc.register(Echo, {
echo: (value) =>
Effect.sync(() => {
received.push(value)
return value
}),
})
expect(Exit.isFailure(yield* rpc.call(Echo.id, "echo", 42).pipe(Effect.exit))).toBe(true)
expect(received).toEqual([])
const Checked = Rpc.define({
id: "checked",
methods: { echo: { input: z.string(), output: z.string().min(3) } },
events: {},
})
yield* rpc.register(Checked, { echo: () => Effect.succeed("a") })
expect(Exit.isFailure(yield* rpc.client(Checked).echo("hello").pipe(Effect.exit))).toBe(true)
}),
)
it.effect("leaves local transport values to the declared schema", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Identity = Rpc.define({
id: "identity",
methods: { echo: { input: Schema.Unknown, output: Schema.Unknown } },
events: {},
})
yield* rpc.register(Identity, { echo: Effect.succeed })
const value = new Date(0)
expect(yield* rpc.client(Identity).echo(value)).toBe(value)
}),
)
it.effect("applies Standard Schema transforms once for inputs, outputs, and events", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const counts = { input: 0, output: 0, event: 0 }
const Transformed = Rpc.define({
id: "transformed",
methods: {
count: {
input: z.string().transform((value) => {
counts.input++
return Number(value)
}),
output: z.number().transform((value) => {
counts.output++
return String(value)
}),
},
},
events: {
counted: {
schema: z.object({ count: z.number() }).transform(({ count }) => {
counts.event++
return { text: String(count) }
}),
},
},
})
const registration = yield* rpc.register(Transformed, { count: (value) => Effect.succeed(value + 1) })
const client = rpc.client(Transformed)
expect(yield* client.count("41")).toBe("42")
const events = yield* client.events
.subscribe("counted")
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* registration.events.emit("counted", { count: 42 })
expect((yield* Fiber.join(events))[0].data).toEqual({ text: "42" })
expect(counts).toEqual({ input: 1, output: 1, event: 1 })
}),
)
it.effect("keeps encoded dispatch and decoded local results consistent for Effect codecs", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Codec = Rpc.define({
id: "codec",
methods: { count: { input: Schema.FiniteFromString, output: Schema.FiniteFromString } },
events: { counted: { schema: Schema.Struct({ count: Schema.FiniteFromString }) } },
})
const registration = yield* rpc.register(Codec, { count: (value) => Effect.succeed(value + 1) })
expect(yield* rpc.call(Codec.id, "count", "41")).toBe("42")
expect(yield* rpc.client(Codec).count("41")).toBe(42)
const events = yield* rpc
.client(Codec)
.events.subscribe("counted")
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* registration.events.emit("counted", { count: 42 })
expect((yield* Fiber.join(events))[0].data).toEqual({ count: 42 })
}),
)
it.effect("validates declared error data and decodes it for local clients", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Failing = Rpc.define({
id: "failing",
methods: {
standard: {
input: z.undefined(),
output: z.string(),
errors: { missing: z.object({ attempts: z.string().transform(Number) }) },
},
effect: {
input: Schema.Undefined,
output: Schema.String,
errors: { invalid: Schema.Struct({ count: Schema.FiniteFromString }) },
},
},
events: {},
})
yield* rpc.register(Failing, {
standard: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: "2" })),
effect: (_input, context) => Effect.fail(context.error("invalid", "Invalid", { count: 3 })),
})
expect(yield* rpc.call(Failing.id, "standard", undefined).pipe(Effect.flip)).toEqual({
type: "missing",
message: "Missing",
data: { attempts: 2 },
})
expect(yield* rpc.client(Failing).standard().pipe(Effect.flip)).toEqual({
type: "missing",
message: "Missing",
data: { attempts: 2 },
})
expect(yield* rpc.call(Failing.id, "effect", undefined).pipe(Effect.flip)).toEqual({
type: "invalid",
message: "Invalid",
data: { count: "3" },
})
expect(yield* rpc.client(Failing).effect().pipe(Effect.flip)).toEqual({
type: "invalid",
message: "Invalid",
data: { count: 3 },
})
}),
)
it.effect("keeps other event consumers running after one subscription ends", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const registration = yield* rpc.register(Echo, { echo: (value) => Effect.succeed(value) })
const client = rpc.client(Echo)
const first = yield* client.events.subscribe("updated").pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const second = yield* client.events
.subscribe("updated")
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* registration.events.emit("updated", { text: "first" })
const received = yield* Fiber.join(first)
expect(received.map((event) => event.data.text)).toEqual(["first"])
Reflect.set(received[0].location, "directory", "/consumer-mutated")
yield* registration.events.emit("updated", { text: "second" })
expect((yield* Fiber.join(second)).map((event) => event.data.text)).toEqual(["first", "second"])
}),
)
it.effect("validates plain JSON Schema inputs and outputs without type inference", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Raw = Rpc.define({
id: "raw",
methods: { count: { input: { type: "integer", minimum: 0 }, output: { type: "integer", minimum: 1 } } },
events: {
counted: {
schema: {
type: "object",
properties: { count: { type: "integer", minimum: 1 } },
required: ["count"],
additionalProperties: false,
},
},
},
})
const registration = yield* rpc.register(Raw, { count: (value) => Effect.succeed(value) })
expect(yield* rpc.call(Raw.id, "count", 42)).toBe(42)
expect(Exit.isFailure(yield* rpc.call(Raw.id, "count", "42").pipe(Effect.exit))).toBe(true)
expect(Exit.isFailure(yield* rpc.call(Raw.id, "count", 0).pipe(Effect.exit))).toBe(true)
expect(Exit.isFailure(yield* registration.events.emit("counted", { count: 0 }).pipe(Effect.exit))).toBe(true)
}),
)
it.effect("supports methods with no input and no returned value", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Empty = Rpc.define({
id: "empty",
methods: { ping: { input: z.undefined(), output: z.undefined() } },
events: {},
})
yield* rpc.register(Empty, { ping: () => Effect.undefined })
expect(yield* rpc.client(Empty).ping()).toBeUndefined()
}),
)
it.effect("keeps in-flight calls on their original implementation after removal", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const registration = yield* rpc.register(Echo, {
echo: (value) =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(value)),
})
const call = yield* rpc.client(Echo).echo("original").pipe(Effect.forkScoped)
yield* Deferred.await(started)
yield* registration.dispose
yield* rpc.register(Echo, { echo: () => Effect.succeed("replacement") })
expect(yield* rpc.client(Echo).echo("hello")).toBe("replacement")
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(call)).toBe("original")
}),
)
it.effect("interrupts the running Effect handler when its call is cancelled", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const started = yield* Deferred.make<void>()
const stopped = yield* Deferred.make<void>()
yield* rpc.register(Echo, {
echo: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(stopped, undefined)),
),
})
const call = yield* rpc.client(Echo).echo("hello").pipe(Effect.forkScoped)
yield* Deferred.await(started)
yield* Fiber.interrupt(call)
yield* Deferred.await(stopped)
}),
)
it.effect("isolates registrations and subscriptions while publishing location-tagged events on the shared bus", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const bus = yield* Bus.Service
const otherRef = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_other") })
const otherContext = yield* Layer.build(
LayerNode.compile(Rpc.node, {
replacements: [
Bus.node.replace(Layer.succeed(Bus.Service, bus)),
Location.node.replace(Layer.succeed(Location.Service, location(otherRef))),
],
}).pipe(Layer.fresh),
)
const other = Context.get(otherContext, Rpc.Service)
const first = yield* rpc.register(Echo, { echo: () => Effect.succeed("first") })
expect(Exit.isFailure(yield* other.client(Echo).echo("hello").pipe(Effect.exit))).toBe(true)
const second = yield* other.register(Echo, { echo: () => Effect.succeed("second") })
expect(yield* rpc.client(Echo).echo("hello")).toBe("first")
expect(yield* other.client(Echo).echo("hello")).toBe("second")
const all: Event.Payload[] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
all.push(event)
}),
)
const localEvents = yield* rpc
.client(Echo)
.events.subscribe("updated")
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const otherEvents = yield* other
.client(Echo)
.events.subscribe("updated")
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* second.events.emit("updated", { text: "second" })
yield* first.events
.emit("updated", { text: "first" })
.pipe(Effect.provideService(Location.Service, location(otherRef)))
expect((yield* Fiber.join(localEvents))[0]).toMatchObject({
type: "rpc.test.rpc.updated",
data: { text: "first" },
location: ref,
})
expect((yield* Fiber.join(otherEvents))[0]).toMatchObject({
type: "rpc.test.rpc.updated",
data: { text: "second" },
location: otherRef,
})
expect(all.map((event) => event.location)).toEqual([otherRef, ref])
yield* unsubscribe
}),
)
})
+39 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, LanguageModel, SystemPart, ToolDefinition, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -22,6 +22,7 @@ import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/core/agent"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
@@ -83,6 +84,7 @@ const it = testEffect(
Bus.node,
SessionProjector.node,
SessionStore.node,
PluginHooks.node,
SessionCompaction.node,
SessionModelRequest.node,
]),
@@ -482,3 +484,39 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
expect(requests[0]?.promptCacheKey).toBe(rootID)
}),
)
it.effect("keeps session context hooks away from compaction requests", () =>
Effect.gen(function* () {
requests = []
const compaction = yield* SessionCompaction.Service
// Context hooks shape the agent conversation; compaction is not part of it,
// so it opts out and the transcript passes through unchanged.
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.system.push(SystemPart.make("Injected conversation context"))
}),
)
const session = yield* insertSession(Session.ID.make("ses_hook_compaction"))
const modelRequests = yield* SessionModelRequest.Service
expect(
yield* compaction.compactManual({
session,
resolveModel: () => Effect.succeed(resolved),
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize this conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
],
inputID: SessionMessage.ID.make("msg_hook_compaction"),
}),
).toEqual({ status: "completed" })
expect(requests).toHaveLength(1)
expect(requests[0]?.system).toEqual([])
}),
)
+31 -3
View File
@@ -1,5 +1,13 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, LLMResponse, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode-ai/ai"
import {
LLMClient,
LLMEvent,
LLMResponse,
LanguageModel,
SystemPart,
ToolDefinition,
type LLMRequest,
} from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { Agent } from "@opencode-ai/core/agent"
@@ -36,6 +44,7 @@ import {
} from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Tool } from "@opencode-ai/core/tool"
import { asc, eq } from "drizzle-orm"
@@ -102,6 +111,7 @@ const discovery = Layer.mock(InstructionDiscovery.Service, {
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const plugins = Layer.mock(PluginSupervisor.Service, { awaitActivation: Effect.void })
const tools = Layer.mock(Tool.Service, {
snapshot: () =>
Effect.succeed({
@@ -131,6 +141,7 @@ const it = testEffect(
SessionStore.node,
Agent.node,
InstructionBuiltIns.node,
PluginHooks.node,
SessionGenerateNode.node,
]),
[
@@ -142,7 +153,7 @@ const it = testEffect(
SkillInstructions.node.replace(skills),
ReferenceInstructions.node.replace(references),
McpInstructions.node.replace(mcp),
PluginSupervisor.node.replace(Layer.empty),
PluginSupervisor.node.replace(plugins),
Tool.node.replace(tools),
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
],
@@ -287,13 +298,29 @@ it.effect(
})
instruction = "Changed context"
const before = yield* durableState(db, sessionID)
const hooks = yield* PluginHooks.Service
let modelRequestHook = false
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.system = [SystemPart.make("Hooked system"), ...event.system]
if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup"
}),
)
yield* hooks.register("session", "model.request", () =>
Effect.sync(() => {
modelRequestHook = true
}),
)
yield* hooks.register("session", "http.request", () => Effect.void)
const generate = yield* SessionGenerate.Service
const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
expect(result).toBe("Transient answer")
expect(requests).toHaveLength(1)
expect(modelRequestHook).toBe(true)
expect(requests[0]?.model).toBe(model)
expect(requests[0]?.system[0]?.text).toBe("Hooked system")
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
expect(requests[0]?.http?.headers).toMatchObject({ "X-Session-Id": sessionID })
expect(requests[0]?.promptCacheKey).toBe(sessionID)
@@ -313,8 +340,9 @@ it.effect(
: [],
),
).toEqual(["Settled partial answer"])
expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Lookup" }])
expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }])
expect(requests[0]?.toolChoice).toBeUndefined()
expect(options[0]?.http).toBeFunction()
expect(options[0]?.webSocket).toBeUndefined()
expect(yield* durableState(db, sessionID)).toEqual(before)
}),
@@ -1,9 +1,169 @@
import { describe, expect, test } from "bun:test"
import { Message, ToolResultPart } from "@opencode-ai/ai"
import { boundImages, unsupportedParts } from "@opencode-ai/core/session/model-request"
import { LanguageModel, Message, ToolResultPart } from "@opencode-ai/ai"
import { Gemini } from "@opencode-ai/ai/protocols/gemini"
import { OpenAIResponses } from "@opencode-ai/ai/protocols/openai-responses"
import { compileRequest } from "@opencode-ai/ai/route/client"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { SessionModelRequest, boundImages, unsupportedParts } from "@opencode-ai/core/session/model-request"
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { Agent } from "@opencode-ai/schema/agent"
import { Location } from "@opencode-ai/schema/location"
import { Money } from "@opencode-ai/schema/money"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { ConfigProvider, DateTime, Effect } from "effect"
import { testEffect } from "./lib/effect"
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
const it = testEffect(
LayerNode.compile(LayerNode.group([SessionModelRequest.node, PluginHooks.node]), {
replacements: [
SessionModelTransport.node.replace(
SessionModelTransport.makeLayer({ open: () => Effect.die("Unexpected connection") }),
),
],
}),
)
const requestInput = (model: LanguageModel) => ({
scope: {
session: Session.Info.make({
id: Session.ID.make("ses_request_options"),
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
}),
agentID: Agent.ID.make("build"),
model: SessionRunnerModel.resolved(model, {
capabilities: { ...capabilities(["text"]), responsesWebsockets: model.provider === "openai" },
cost: [],
limit: { context: 200_000, output: 32_000 },
}),
},
transcript: { system: [], messages: [Message.user("Hello")] },
})
describe("SessionModelRequest.context options", () => {
it.effect("compiles ordered generation and provider overrides without mutating defaults", () =>
Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service
const hooks = yield* PluginHooks.Service
const model = Gemini.route
.with({
generation: { maxTokens: 100, topP: 0.7 },
providerOptions: { thinkingConfig: { includeThoughts: true, thinkingBudget: 256 } },
})
.model({
id: "gemini-2.5-flash",
defaults: {
generation: { temperature: 0.8 },
providerOptions: { thinkingConfig: { thinkingBudget: 512 } },
},
})
const baseline = yield* requests.prepare(requestInput(model))
expect(baseline.request.generation).toBeUndefined()
expect(baseline.request.providerOptions).toBeUndefined()
const first = yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
expect(event.generation).toEqual({})
expect(event.providerOptions).toEqual({})
event.generation = {
maxTokens: 2048,
temperature: 0.2,
topK: 40,
frequencyPenalty: 0.1,
presencePenalty: 0.3,
seed: 42,
stop: ["END"],
}
event.providerOptions = { thinkingConfig: { thinkingBudget: 1024 } }
}),
)
const second = yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
expect(event.generation.temperature).toBe(0.2)
expect(event.providerOptions.thinkingConfig).toEqual({ thinkingBudget: 1024 })
event.generation.temperature = 0
event.generation.stop?.push("STOP")
}),
)
const prepared = yield* requests.prepare(requestInput(model))
expect((yield* compileRequest(prepared.request)).body).toMatchObject({
generationConfig: {
maxOutputTokens: 2048,
temperature: 0,
topP: 0.7,
topK: 40,
frequencyPenalty: 0.1,
presencePenalty: 0.3,
seed: 42,
stopSequences: ["END", "STOP"],
thinkingConfig: { includeThoughts: true, thinkingBudget: 1024 },
},
})
// Each new request starts with fresh override objects, even while hooks remain registered.
expect((yield* requests.prepare(requestInput(model))).request.generation).toEqual(prepared.request.generation)
yield* first.dispose
yield* second.dispose
const unhooked = yield* requests.prepare(requestInput(model))
expect(unhooked.request.generation).toBeUndefined()
expect(unhooked.request.providerOptions).toBeUndefined()
expect((yield* compileRequest(unhooked.request)).body).toEqual((yield* compileRequest(baseline.request)).body)
expect(model.defaults?.generation).toEqual({ temperature: 0.8 })
expect(model.route.defaults.generation).toEqual({ maxTokens: 100, topP: 0.7 })
expect(model.defaults?.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 512 } })
expect(model.route.defaults.providerOptions).toEqual({
thinkingConfig: { includeThoughts: true, thinkingBudget: 256 },
})
}),
)
it.effect("compiles OpenAI semantic reasoning options without revoking WebSocket transport", () =>
Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", () => Effect.die("Other-provider hook must not run"), {
providerID: "google",
})
yield* hooks.register(
"session",
"context",
(event) =>
Effect.sync(() => {
event.generation.maxTokens = 8000
event.providerOptions.reasoningEffort = "high"
}),
{ providerID: "openai" },
)
const input = requestInput(OpenAIResponses.route.model({ id: "gpt-5.5" }))
const prepared = yield* requests.prepare({ ...input, webSocket: "session" })
expect(prepared.options.webSocket).toBeDefined()
expect(prepared.options.http).toBeUndefined()
expect((yield* compileRequest(prepared.request)).body).toMatchObject({
max_output_tokens: 8000,
reasoning: { effort: "high" },
store: false,
include: ["reasoning.encrypted_content"],
})
const excluded = yield* requests.prepare({ ...input, contextHooks: false })
expect(excluded.request.generation).toBeUndefined()
expect(excluded.request.providerOptions).toBeUndefined()
}).pipe(
Effect.provide(
ConfigProvider.layer(
ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET: "true" } }),
),
),
),
)
})
describe("SessionModelRequest.unsupportedParts", () => {
test("replaces unsupported user media with a visible error", () => {
const messages = unsupportedParts(
+63 -10
View File
@@ -17,8 +17,8 @@ import { EventTable } from "../src/event/sql.js"
import { Image } from "../src/image.js"
import { Instance } from "../src/instance/service.js"
import { Location } from "../src/location.js"
import { Plugin } from "../src/plugin.js"
import { PluginHooks } from "../src/plugin/hooks.js"
import { PluginSupervisor } from "../src/plugin/supervisor-service.js"
import { ProjectTable } from "../src/project/sql.js"
import { Reference } from "../src/reference.js"
import { AbsolutePath, RelativePath } from "../src/schema.js"
@@ -142,7 +142,7 @@ const setup = Effect.fnUntraced(function* (options?: {
get: (id) => Effect.succeed(id === skillInfo.id ? skillInfo : undefined),
}),
options?.snapshot?.(ref) ?? Layer.mock(Snapshot.Service, {}),
Layer.mock(Plugin.Service, {
Layer.succeed(PluginSupervisor.Service, {
awaitActivation: Effect.sync(() => {
activationWaits.push(ref)
}),
@@ -574,6 +574,34 @@ describe("Session-owned handles", () => {
}),
)
it.live("allows a prompt hook to admit synthetic input through another handle for the same Session", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
const nested = fixture.sessions.forSession(sessionID)
yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.gen(function* () {
expect(event.sessionID).toBe(sessionID)
yield* nested.synthetic({ text: "Admitted by hook", resume: false })
event.prompt.text += " prepared"
}).pipe(Effect.orDie),
)
const prompt = yield* handle.prompt({ text: "Original", resume: false })
expect(yield* handle.inbox()).toMatchObject([
{ type: "synthetic", payload: { text: "Admitted by hook" } },
{ id: prompt.id, type: "user", payload: { text: "Original prepared" } },
])
yield* SessionInbox.promote(fixture.db, fixture.bus, sessionID, "steer")
expect(yield* fixture.store.context(sessionID)).toMatchObject([
{ type: "synthetic", text: "Admitted by hook" },
{ type: "user", text: "Original prepared" },
])
expect(fixture.locations).toEqual([source])
}),
)
it.live("mutates only this handle's pending inbox and preserves public conflict tags", () =>
Effect.gen(function* () {
const fixture = yield* setup()
@@ -755,16 +783,27 @@ describe("Session-owned handles", () => {
})
describe("SessionPrompt construction", () => {
it.live("captures preparation dependencies without admitting input", () =>
it.live("captures preparation dependencies without admitting input and checks readiness on every call", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const calls: string[] = []
yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.sync(() => {
calls.push("hook")
event.prompt.text += " prepared"
}),
)
const { prepare } = yield* SessionPrompt.Service.pipe(
Effect.provide(
SessionPrompt.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.succeed(PluginHooks.Service, fixture.hooks),
Layer.mock(Plugin.Service, { awaitActivation: Effect.void }),
Layer.succeed(PluginSupervisor.Service, {
awaitActivation: Effect.sync(() => {
calls.push("ready")
}),
}),
Layer.mock(Image.Service, {}),
Layer.mock(Skill.Service, {}),
),
@@ -772,6 +811,7 @@ describe("SessionPrompt construction", () => {
),
),
)
expect(calls).toEqual([])
const input = { text: "Original", files: [{ uri: new URL("./session-owned.test.ts", import.meta.url).href }] }
const request = { sessionID, messageID: SessionMessage.ID.create(), input }
const items = yield* Effect.forEach([0, 1], () => prepare(request)).pipe(
@@ -779,8 +819,9 @@ describe("SessionPrompt construction", () => {
Effect.setContext(Context.empty()),
)
expect(calls).toEqual(["ready", "hook", "ready", "hook"])
expect(items[0]).toEqual(items[1])
expect(items[0]).toMatchObject({ type: "user", payload: { text: "Original" }, delivery: "steer" })
expect(items[0]).toMatchObject({ type: "user", payload: { text: "Original prepared" }, delivery: "steer" })
expect(items[0]?.payload.files?.[0]?.mime).toBe("text/plain")
expect(input.text).toBe("Original")
expect(yield* fixture.sessions.forSession(sessionID).inbox()).toEqual([])
@@ -790,7 +831,7 @@ describe("SessionPrompt construction", () => {
})
describe("SessionRevert construction", () => {
it.live("captures dependencies without doing work", () =>
it.live("captures dependencies without work, then checks readiness on every stage and clear", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
@@ -800,7 +841,11 @@ describe("SessionRevert construction", () => {
const revert = yield* SessionRevert.make().pipe(
Effect.provide(
Layer.merge(
Layer.mock(Plugin.Service, { awaitActivation: Effect.void }),
Layer.succeed(PluginSupervisor.Service, {
awaitActivation: Effect.sync(() => {
calls.push("awaitActivation")
}),
}),
Layer.mock(Snapshot.Service, {
capture: () =>
Effect.sync(() => {
@@ -821,12 +866,12 @@ describe("SessionRevert construction", () => {
),
)
expect(calls).toEqual([])
const unrelated = Layer.mock(Snapshot.Service, {})
const unrelated = Layer.merge(Layer.mock(PluginSupervisor.Service, {}), Layer.mock(Snapshot.Service, {}))
const session = yield* handle.get()
yield* revert
.stage({ session, messageID: boundary.id, files: false })
.pipe(Effect.satisfiesServicesType<never>(), Effect.provide(unrelated))
expect(calls).toEqual(["capture", "capture", "diff"])
expect(calls).toEqual(["awaitActivation", "capture", "capture", "diff"])
const staged = yield* handle.get()
expect(staged.revert?.snapshot).toBe(Snapshot.ID.make("captured-tree"))
@@ -834,7 +879,15 @@ describe("SessionRevert construction", () => {
const cleared = yield* handle.get()
expect(cleared.revert).toBeUndefined()
yield* revert.clear(cleared).pipe(Effect.satisfiesServicesType<never>(), Effect.provide(unrelated))
expect(calls).toEqual(["capture", "capture", "diff", "restore"])
expect(calls).toEqual([
"awaitActivation",
"capture",
"capture",
"diff",
"awaitActivation",
"restore",
"awaitActivation",
])
}),
)
})
@@ -0,0 +1,352 @@
import { describe, expect, setDefaultTimeout } from "bun:test"
import path from "path"
import { Deferred, Effect, Fiber, Stream } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Skill } from "@opencode-ai/core/skill"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
// These tests include real Location and plugin startup, not just hook callbacks.
setDefaultTimeout(15_000)
const runtime = PluginRuntime.makeCell()
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
Session.node,
LocationServiceMap.node,
PluginRuntime.providerNodeWithCell(runtime),
]),
[
Bus.node.replace(Bus.configured({ persist: true })),
Global.node.replace(tempGlobalLayer),
Watcher.node.replace(Watcher.configured({ enabled: false })),
SessionExecution.node.replace(SessionExecution.noopLayer),
PluginRuntime.node.replace(PluginRuntime.layerWithCell(runtime)),
],
),
)
const project = Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const setup = Effect.gen(function* () {
const tmp = yield* project
const sessions = yield* Session.Service
const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } })
const locations = yield* LocationServiceMap.Service
const services = locations.get(session.location)
const hooks = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.awaitActivation
return yield* PluginHooks.Service
}).pipe(Effect.provide(services))
return { sessions, session, hooks, services }
})
describe("Session prompt hooks", () => {
it.live("waits for local plugin setup before admitting even a plain-text prompt", () =>
Effect.gen(function* () {
const tmp = yield* project
yield* Effect.promise(() =>
Bun.write(
path.join(tmp.path, ".opencode/plugins/prompt.ts"),
`export default {
id: "prompt-readiness",
async setup(ctx) {
await ctx.session.hook("prompt", (event) => {
event.prompt.text = "Prepared by plugin"
})
},
}`,
),
)
const sessions = yield* Session.Service
const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } })
const admitted = yield* sessions.prompt({ sessionID: session.id, text: "Original", resume: false })
expect(admitted.payload.text).toBe("Prepared by plugin")
}),
)
it.live("persists ordered draft edits and resolves added files and skills without mutating the caller", () =>
Effect.gen(function* () {
const fixture = yield* setup
const skills = yield* Skill.Service.pipe(Effect.provide(fixture.services))
const skill = Skill.Info.make({
id: Skill.ID.make("policy"),
name: Skill.Name.make("Policy"),
description: "Company policy",
location: AbsolutePath.make(path.join(fixture.session.location.directory, "policy.md")),
content: "Follow company policy.",
})
yield* skills.transform((draft) => draft.add(skill))
const input = {
sessionID: fixture.session.id,
id: SessionMessage.ID.create(),
text: "secret",
files: [
{
uri: "data:text/plain;base64,b3JpZ2luYWw=",
name: "original.txt",
mention: { start: 0, end: 6, text: "secret" },
},
],
metadata: { source: "api" },
resume: false,
}
yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.sync(() => {
expect(event.sessionID).toBe(input.sessionID)
expect(event.messageID).toBe(input.id)
event.prompt.text = "Redacted"
const file = event.prompt.files?.[0]
if (file) {
file.uri = "data:text/plain;base64,cG9saWN5"
file.name = "policy.txt"
delete file.mention
}
event.prompt.skills = [{ id: skill.id }]
event.prompt.agents = [{ name: "reviewer" }]
event.metadata ??= {}
event.metadata.source = "plugin"
event.delivery = "queue"
}),
)
yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.sync(() => {
expect(event.prompt.text).toBe("Redacted")
event.prompt.text += " with policy"
}),
)
const admitted = yield* fixture.sessions.prompt(input)
expect(admitted).toMatchObject({
id: input.id,
delivery: "queue",
payload: {
text: "Redacted with policy",
metadata: { source: "plugin" },
files: [{ name: "policy.txt", data: "cG9saWN5", mime: "text/plain" }],
agents: [{ name: "reviewer" }],
skills: [{ id: skill.id, name: skill.name, text: Skill.toModelOutput(skill, []) }],
},
})
expect(input.text).toBe("secret")
expect(input.files).toEqual([
{
uri: "data:text/plain;base64,b3JpZ2luYWw=",
name: "original.txt",
mention: { start: 0, end: 6, text: "secret" },
},
])
expect(input.metadata).toEqual({ source: "api" })
const database = yield* Database.Service
const bus = yield* Bus.Service
expect(yield* SessionInbox.find(database.db, input.id)).toEqual(admitted)
const log = yield* fixture.sessions.log({ sessionID: input.sessionID }).pipe(Stream.runCollect)
expect(JSON.stringify(log)).not.toContain("secret")
yield* SessionInbox.promote(database.db, bus, input.sessionID, "input")
expect(yield* fixture.sessions.messages({ sessionID: input.sessionID })).toMatchObject([
{ id: input.id, type: "user", text: "Redacted with policy", metadata: { source: "plugin" } },
])
}),
)
it.live("skips hooks and payload resolution on pending and delivered retries, including conflicts", () =>
Effect.gen(function* () {
const fixture = yield* setup
const calls: string[] = []
yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.sync(() => {
calls.push(event.prompt.text)
event.prompt.text = "First admission"
}),
)
const input = { sessionID: fixture.session.id, id: SessionMessage.ID.create(), text: "Original", resume: false }
const first = yield* fixture.sessions.prompt(input)
const retry = { ...input, text: "Ignored", files: [{ uri: "file:///missing-retry-file" }] }
expect(yield* fixture.sessions.prompt(retry)).toEqual(first)
const database = yield* Database.Service
const bus = yield* Bus.Service
yield* SessionInbox.promote(database.db, bus, input.sessionID, "steer")
expect((yield* fixture.sessions.prompt(retry)).payload).toEqual(first.payload)
const other = yield* fixture.sessions.create({ location: fixture.session.location })
expect((yield* fixture.sessions.prompt({ ...retry, sessionID: other.id }).pipe(Effect.flip))._tag).toBe(
"Session.PromptConflictError",
)
const synthetic = yield* fixture.sessions.synthetic({
sessionID: input.sessionID,
text: "Synthetic",
resume: false,
})
expect((yield* fixture.sessions.prompt({ ...retry, id: synthetic.id }).pipe(Effect.flip))._tag).toBe(
"Session.PromptConflictError",
)
expect(calls).toEqual(["Original"])
}),
)
it.live("leaves a staged revert untouched on retries and failed preparation", () =>
Effect.gen(function* () {
const fixture = yield* setup
const database = yield* Database.Service
const bus = yield* Bus.Service
const first = yield* fixture.sessions.prompt({ sessionID: fixture.session.id, text: "Boundary", resume: false })
yield* SessionInbox.promote(database.db, bus, fixture.session.id, "steer")
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID: fixture.session.id,
revert: { messageID: first.id, files: [] },
})
const failing = yield* fixture.hooks.register("session", "prompt", () => Effect.die(new Error("Broken hook")))
expect(
(yield* fixture.sessions.prompt({
sessionID: fixture.session.id,
id: first.id,
text: "Ignored",
resume: false,
})).payload,
).toEqual(first.payload)
expect(
(yield* fixture.sessions
.prompt({ sessionID: fixture.session.id, text: "Fail", resume: false })
.pipe(Effect.exit))._tag,
).toBe("Failure")
expect((yield* fixture.sessions.get(fixture.session.id)).revert?.messageID).toBe(first.id)
expect(yield* fixture.sessions.messages({ sessionID: fixture.session.id })).toMatchObject([{ id: first.id }])
yield* failing.dispose
const next = yield* fixture.sessions.prompt({
sessionID: fixture.session.id,
text: "After revert",
resume: false,
})
expect((yield* fixture.sessions.get(fixture.session.id)).revert).toBeUndefined()
expect(yield* fixture.sessions.messages({ sessionID: fixture.session.id })).toEqual([])
expect(yield* fixture.sessions.inbox(fixture.session.id)).toEqual([next])
}),
)
it.live("keeps first-admission-wins for concurrent transformed submissions", () =>
Effect.gen(function* () {
const fixture = yield* setup
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const calls: string[] = []
yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.gen(function* () {
calls.push(event.prompt.text)
event.prompt.text += " transformed"
if (calls.length === 2) yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
}),
)
const input = { sessionID: fixture.session.id, id: SessionMessage.ID.create(), text: "First", resume: false }
const submissions = yield* Effect.all(
[fixture.sessions.prompt(input), fixture.sessions.prompt({ ...input, text: "Second" })],
{ concurrency: "unbounded" },
).pipe(Effect.forkChild)
yield* Deferred.await(entered)
expect(yield* fixture.sessions.inbox(input.sessionID)).toEqual([])
yield* Deferred.succeed(release, undefined)
const results = yield* Fiber.join(submissions)
expect(results[0]).toEqual(results[1])
expect(["First transformed", "Second transformed"]).toContain(results[0]?.payload.text)
expect(yield* fixture.sessions.inbox(input.sessionID)).toHaveLength(1)
expect(yield* fixture.sessions.prompt(input)).toEqual(results[0])
expect(calls).toHaveLength(2)
}),
)
it.live("does not admit failed attachment preparation or an interrupted hook", () =>
Effect.gen(function* () {
const fixture = yield* setup
const registration = yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.sync(() => {
event.prompt.files = [{ uri: "file:///missing-hook-file" }]
}),
)
expect(
(yield* fixture.sessions
.prompt({ sessionID: fixture.session.id, text: "Original", resume: false })
.pipe(Effect.flip))._tag,
).toBe("Session.AttachmentError")
yield* registration.dispose
const failing = yield* fixture.hooks.register("session", "prompt", () => Effect.die(new Error("Broken hook")))
expect(
(yield* fixture.sessions
.prompt({ sessionID: fixture.session.id, text: "Fail", resume: false })
.pipe(Effect.exit))._tag,
).toBe("Failure")
yield* failing.dispose
const started = yield* Deferred.make<void>()
yield* fixture.hooks.register("session", "prompt", () =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
)
const submission = yield* fixture.sessions
.prompt({ sessionID: fixture.session.id, text: "Interrupt", resume: false })
.pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Fiber.interrupt(submission)
expect(yield* fixture.sessions.inbox(fixture.session.id)).toEqual([])
expect(yield* fixture.sessions.messages({ sessionID: fixture.session.id })).toEqual([])
}),
)
it.live("applies a Promise plugin to command-generated prompts only in its own location", () =>
Effect.gen(function* () {
const tmp = yield* project
yield* Effect.promise(() =>
Bun.write(
path.join(tmp.path, ".opencode/plugins/command.ts"),
`export default {
id: "prompt-command",
async setup(ctx) {
await ctx.session.hook("prompt", (event) => {
event.prompt.text += " with plugin"
})
await ctx.command.transform((draft) => {
draft.add({
name: "review",
async execute(input) {
await ctx.session.prompt({ sessionID: input.sessionID, text: "Review", resume: false })
},
})
})
},
}`,
),
)
const sessions = yield* Session.Service
const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } })
const other = yield* setup
yield* sessions.command({ sessionID: session.id, command: "review", text: "" })
expect(yield* sessions.inbox(session.id)).toMatchObject([{ payload: { text: "Review with plugin" } }])
const untouched = yield* other.sessions.prompt({
sessionID: other.session.id,
text: "Other location",
resume: false,
})
expect(untouched.payload.text).toBe("Other location")
}),
)
})
+47 -11
View File
@@ -31,7 +31,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Image } from "@opencode-ai/core/image"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Skill } from "@opencode-ai/core/skill"
@@ -87,9 +87,11 @@ const locations = (references: Layer.Layer<Reference.Service>) =>
)
return yield* LayerMap.make(
(_ref: Location.Ref) =>
// These operations resolve Location services lazily and must wait for plugin-projected state.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.suspend(() =>
Layer.merge(SessionRevert.layer, SessionPrompt.layer).pipe(
Layer.suspend(() => {
let ready = false
return Layer.merge(SessionRevert.layer, SessionPrompt.layer).pipe(
Layer.provideMerge(
Layer.mergeAll(
references,
@@ -98,21 +100,30 @@ const locations = (references: Layer.Layer<Reference.Service>) =>
}),
Layer.mock(Image.Service, {
normalize: (_resource, content) =>
Effect.succeed(
content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content,
),
ready
? Effect.succeed(
content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content,
)
: Effect.die(new Error("Image service used before plugins were ready")),
}),
Layer.mock(Snapshot.Service, {
capture: () => Effect.undefined,
restore: () => Effect.void,
capture: () =>
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () =>
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
}),
Layer.mock(Plugin.Service, { awaitActivation: Effect.void }),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({
awaitActivation: Effect.sync(() => (ready = true)),
}),
),
),
),
Layer.provide(shared),
Layer.fresh,
),
) as unknown as Layer.Layer<LocationServices>,
)
}) as unknown as Layer.Layer<LocationServices>,
)
}),
),
@@ -1199,6 +1210,31 @@ describe("Session.prompt", () => {
)
})
describe("Session.revert", () => {
it.effect("waits for location plugins before staging", () =>
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const session = yield* Session.Service
yield* db.insert(SessionMessageTable).values(assistantRow(messageID, 0)).run().pipe(Effect.orDie)
yield* session.revert.stage({ sessionID, messageID })
}),
)
it.effect("waits for location plugins before clearing", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
revert: { messageID, snapshot: Snapshot.ID.make("tree"), files: [] },
})
yield* session.revert.clear(sessionID)
}),
)
})
describe("Session.inbox", () => {
it.effect("fails for an unknown session", () =>
Effect.gen(function* () {
+2 -2
View File
@@ -9,7 +9,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -66,7 +66,7 @@ describe("Session.revert files", () => {
expect(yield* SessionRevert.Service.pipe(Effect.provide(services))).toBe(revert)
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const plugins = yield* PluginSupervisor.Service
yield* plugins.awaitActivation
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
@@ -38,6 +38,7 @@ import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import path from "node:path"
import { testEffect } from "./lib/effect"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
@@ -84,6 +85,10 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
})
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Config.testLayer()
const pluginSupervisor = Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ awaitActivation: Effect.void }),
)
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.undefined,
@@ -111,7 +116,7 @@ const runnerLayer = (llmClient: Layer.Layer<LLMClientService>) =>
McpInstructions.node.replace(mcpInstructions),
Config.node.replace(config),
Permission.node.replace(permission),
PluginSupervisor.node.replace(Layer.empty),
PluginSupervisor.node.replace(pluginSupervisor),
])
const execution = (llmClient: Layer.Layer<LLMClientService>) =>
Layer.effect(
@@ -166,7 +171,7 @@ const testLayer = (llmClient: Layer.Layer<LLMClientService>) =>
ReferenceInstructions.node.replace(referenceInstructions),
Config.node.replace(config),
Snapshot.node.replace(Snapshot.noopLayer),
PluginSupervisor.node.replace(Layer.empty),
PluginSupervisor.node.replace(pluginSupervisor),
SessionExecution.node.replace(execution(llmClient)),
],
)
@@ -247,3 +252,106 @@ describe("SessionRunnerLLM recorded", () => {
}),
)
})
describe("SessionModelRequest HTTP bridge", () => {
const bodies: Uint8Array[] = []
const methods: string[] = []
const headers: Array<string | undefined> = []
const response = [
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
"data: [DONE]",
"",
].join("\n\n")
const transport = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
methods.push(request.method)
bodies.push(request.body.body.slice())
headers.push(request.headers["x-hook"])
return HttpClientResponse.fromWeb(
request,
new Response(response, { headers: { "content-type": "text/event-stream" } }),
)
}),
),
)
const httpIt = testEffect(
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
)
httpIt.effect("runs Effect HTTP request and response hooks around one provider request", () =>
Effect.gen(function* () {
bodies.length = 0
methods.length = 0
headers.length = 0
const seen: string[] = []
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("build"), (agent) => {
agent.mode = "primary"
agent.permissions.push({ action: "execute", resource: "*", effect: "deny" })
}),
)
const pluginHost = host({
agent: agentHost(agents),
catalog: catalogHost(catalog),
session: { hook: (name, callback) => hooks.register("session", name, callback) },
})
yield* pluginHost.session.hook("http.request", (event) =>
Effect.sync(() => {
seen.push("request")
event.request.headers.set("x-hook", "effect")
}),
)
yield* pluginHost.session.hook("http.response", (event) =>
Effect.gen(function* () {
seen.push(`response:${event.response.status}:${event.request.headers.get("x-hook")}`)
event.response = new Response(
(yield* Effect.promise(() => event.response.text())).replace("Hello!", "Hooked!"),
event.response,
)
}),
)
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const sessionID = Session.ID.make("ses_model_request_http")
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const session = yield* Session.Service
yield* session.prompt({ sessionID, text: "Say hello.", resume: false })
yield* session.resume(sessionID)
expect(methods).toEqual(["POST"])
expect(headers).toEqual(["effect"])
expect(seen).toEqual(["request", "response:200:effect"])
expect(bodies).toHaveLength(1)
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
expect((yield* session.context(sessionID))[1]).toMatchObject({
type: "assistant",
content: [{ type: "text", text: "Hooked!" }],
})
}),
)
})
+236 -1
View File
@@ -5,6 +5,7 @@ import {
LLMRequest,
Message,
LanguageModel,
SystemPart,
ToolFailure,
TransportError,
InvalidProviderOutputError,
@@ -32,9 +33,11 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionContext } from "@opencode-ai/core/session/context"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
import { Money } from "@opencode-ai/schema/money"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -61,6 +64,7 @@ import {
SessionTable,
} from "@opencode-ai/core/session/sql"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
@@ -74,6 +78,7 @@ import { Location } from "@opencode-ai/core/location"
import { Provider } from "@opencode-ai/core/provider"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { asc, desc, eq, sql } from "drizzle-orm"
import { testEffect } from "./lib/effect"
import { promptLocationNode } from "./fixture/prompt-location"
@@ -201,6 +206,7 @@ const makeRunnerState = () => {
systemUnavailable: false,
systemLoadHook: Effect.void,
skillBaselines: new Map<Agent.ID, string>(),
pluginActivationHook: Effect.void,
authorizations: new Array<Tool.Context>(),
executions: new Array<string>(),
closedTransports: new Array<Session.ID>(),
@@ -381,6 +387,12 @@ const layer = Layer.unwrap(
}),
}),
])
const pluginSupervisor = Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({
awaitActivation: Effect.suspend(() => state.pluginActivationHook),
}),
)
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.undefined,
@@ -406,7 +418,7 @@ const layer = Layer.unwrap(
ReferenceInstructions.node.replace(referenceInstructions),
Permission.node.replace(permission),
Config.node.replace(config),
PluginSupervisor.node.replace(Layer.empty),
PluginSupervisor.node.replace(pluginSupervisor),
SessionModelTransport.node.replace(modelTransport),
]
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
@@ -464,7 +476,9 @@ const layer = Layer.unwrap(
ReferenceInstructions.node,
Config.node,
Snapshot.node,
SessionContext.node,
SessionCompaction.node,
SessionModelRequest.node,
SessionRunnerLLM.node,
SessionExecution.node,
Session.node,
@@ -543,6 +557,7 @@ const setup = Effect.gen(function* () {
admit,
resume,
context: session.context(sessionID),
hooks,
messages: session.messages({ sessionID }),
inbox: session.inbox(sessionID),
runPrompt: Effect.fnUntraced(function* (text: string) {
@@ -1015,6 +1030,137 @@ describe("SessionRunnerLLM", () => {
expect((yield* s.session.get(sessionID)).title).toBe("Generated title")
})
scenario("applies session context hooks without exposing unavailable tools", function* (s) {
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.system = [SystemPart.make("Hooked system")]
event.messages = [Message.user("Hooked message")]
delete event.tools.echo
event.tools.unregistered = { description: "Unavailable", input: { type: "object" } }
event.generation.temperature = 0.2
event.generation.topP = 0.9
event.generation.topK = 40
event.generation.maxTokens = 2048
event.providerOptions.reasoningEffort = "high"
}),
)
yield* s.admit("Original message")
yield* s.llm.push(TestLLM.tool("call-removed", "echo", { text: "blocked" }))
yield* s.resume
// A hook-removed call fails independently and continues while step allowance remains.
expect(s.requests).toHaveLength(2)
expect(s.requests[0]?.system.map((part) => part.text)).toEqual(["Hooked system"])
expect(s.requests[0]?.messages).toEqual([Message.user("Hooked message")])
expect(s.requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
expect(s.requests[0]?.tools.map((tool) => tool.name)).not.toContain("unregistered")
expect(s.requests[0]?.generation).toMatchObject({ temperature: 0.2, topP: 0.9, topK: 40, maxTokens: 2048 })
expect(s.requests[0]?.providerOptions).toEqual({ reasoningEffort: "high" })
expect(s.executions).toEqual([])
expect(yield* s.context).toMatchObject([
Expected.user("Original message"),
Expected.assistant({}, [Expected.failedTool({ id: "call-removed" }, { error: { type: "tool.execution" } })]),
])
})
scenario("keeps WebSocket eligibility after model request hooks", function* (s) {
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
event.headers["x-model-request-hook"] = "active"
}),
)
yield* hooks.register("session", "http.request", () => Effect.die("Other-provider HTTP hook should not apply"), {
providerID: Provider.ID.githubCopilot,
})
const context = yield* SessionContext.Service
const modelRequests = yield* SessionModelRequest.Service
const selected = yield* context.select(sessionID)
yield* InstructionState.prepare(s.db, s.bus, selected.instructions, sessionID)
const loaded = yield* context.load(selected)
const prepared = yield* modelRequests.prepare({
scope: {
session: loaded.session,
agentID: loaded.agent.id,
model: loaded.model,
tools: loaded.tools,
},
transcript: { system: [], messages: [] },
webSocket: "session",
})
expect(prepared.request.http?.headers?.["x-model-request-hook"]).toBe("active")
// No forced HTTP middleware: the other-provider hook must not revoke eligibility.
expect(prepared.options.http).toBeUndefined()
})
scenario("forces HTTP and triggers active request and response hooks once", function* (s) {
const hooks = yield* PluginHooks.Service
let requestTriggers = 0
let responseTriggers = 0
yield* hooks.register("session", "http.request", (event) =>
Effect.sync(() => {
requestTriggers++
event.request.headers.set("x-request-hook", "active")
}),
)
yield* hooks.register("session", "http.response", (event) =>
Effect.sync(() => {
responseTriggers++
event.response.headers.set("x-response-hook", "active")
}),
)
const context = yield* SessionContext.Service
const modelRequests = yield* SessionModelRequest.Service
const selected = yield* context.select(sessionID)
yield* InstructionState.prepare(s.db, s.bus, selected.instructions, sessionID)
const loaded = yield* context.load(selected)
const prepared = yield* modelRequests.prepare({
scope: {
session: loaded.session,
agentID: loaded.agent.id,
model: loaded.model,
tools: loaded.tools,
},
transcript: { system: [], messages: [] },
webSocket: "session",
})
const http = prepared.options.http ?? (yield* Effect.die("Expected Session HTTP middleware"))
const response = yield* http(HttpClientRequest.post("https://provider.test/responses"), (request) => {
expect(request.headers["x-request-hook"]).toBe("active")
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("network")))
})
expect(prepared.options.webSocket).toBeUndefined()
expect(response.headers["x-response-hook"]).toBe("active")
expect(requestTriggers).toBe(1)
expect(responseTriggers).toBe(1)
})
scenario("executes a tool renamed by a session context hook", function* (s) {
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.tools.renamed_echo = event.tools.echo!
delete event.tools.echo
}),
)
yield* s.admit("Use the renamed tool")
yield* s.llm.push(TestLLM.tool("call-renamed", "renamed_echo", { text: "renamed" }), [])
yield* s.resume
expect(s.requests[0]?.tools.map((tool) => tool.name)).toContain("renamed_echo")
expect(s.requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
expect(s.executions).toEqual(["renamed"])
})
scenario("advertises and executes a location registered tool", function* (s) {
const registry = yield* Tool.Service
const contexts: Tool.Context[] = []
@@ -1692,6 +1838,24 @@ describe("SessionRunnerLLM", () => {
expect(s.requests).toHaveLength(0)
})
scenario("waits for initial plugin readiness before constructing the model request", function* (s) {
const release = yield* Deferred.make<void>()
s.pluginActivationHook = Deferred.await(release)
yield* s.session.prompt({ sessionID, text: "Wait for plugins", resume: false })
s.requests.length = 0
yield* s.llm.push([])
const running = yield* s.resume.pipe(Effect.forkChild({ startImmediately: true }))
yield* Effect.yieldNow
expect(s.requests).toHaveLength(0)
expect(running.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(running)
expect(s.requests).toHaveLength(1)
})
scenario("updates selected-agent skill instructions after an agent switch", function* (s) {
const agents = yield* Agent.Service
yield* agents.transform((draft) =>
@@ -4232,6 +4396,77 @@ describe("SessionRunnerLLM", () => {
expect((yield* s.context).filter((message) => message.type === "assistant")).toHaveLength(1)
})
scenario("allows session retry hooks to veto a proposed retry", function* (s) {
const failure = providerUnavailable()
let observed: PluginHooks.Domains["session"]["retry"] | undefined
yield* s.hooks.register("session", "retry", (event) =>
Effect.sync(() => {
observed = event
event.decision = { retry: false }
}),
)
yield* s.llm.push(Stream.fail(failure))
expect(yield* s.runPrompt("Do not retry transport").pipe(Effect.flip)).toBe(failure)
expect(s.requests).toHaveLength(1)
expect(observed).toMatchObject({
sessionID,
agent: "build",
model: { providerID: "fake", id: "fake-model" },
error: { type: "provider.transport", message: "Provider unavailable" },
attempt: 2,
decision: { retry: false },
})
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
})
scenario("allows session retry hooks to retry a terminal provider failure", function* (s) {
yield* s.hooks.register("session", "retry", (event) =>
Effect.sync(() => {
expect(event.decision).toEqual({ retry: false })
event.decision = { retry: true, delay: 0 }
}),
)
yield* s.admit("Retry invalid request")
yield* s.llm.push(Stream.fail(invalidRequest()), TestLLM.text("Recovered", "forced-retry-success"))
yield* s.resume
expect(s.requests).toHaveLength(2)
expect(yield* s.context).toMatchObject([
Expected.user("Retry invalid request"),
Expected.assistant({ finish: "stop" }, [Expected.text("Recovered")]),
])
})
scenario("uses the final session retry hook delay", function* (s) {
yield* s.hooks.register("session", "retry", (event) =>
Effect.sync(() => {
event.decision = { retry: true, delay: 10_000 }
}),
)
yield* s.hooks.register("session", "retry", (event) =>
Effect.sync(() => {
expect(event.decision).toEqual({ retry: true, delay: 10_000 })
event.decision = { retry: true, delay: 5_000 }
}),
)
yield* s.admit("Use custom retry delay")
yield* s.llm.push(Stream.fail(providerUnavailable()), TestLLM.text("Recovered", "hook-delay-success"))
const scheduled = yield* subscribeRetries(s)
const run = yield* s.resume.pipe(Effect.forkChild)
yield* Queue.take(scheduled)
yield* TestClock.adjust("4999 millis")
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("1 millis")
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
const assistant = requireAssistant(yield* s.context)
expect(assistant.retry).toBeUndefined()
})
scenario("does not start another physical attempt after interruption during retry backoff", function* (s) {
yield* s.admit("Interrupt retry backoff")
yield* s.llm.push(Stream.fail(providerUnavailable()), TestLLM.text("Must not run", "unused-retry"))
+4 -2
View File
@@ -12,7 +12,7 @@ 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 { Project } from "@opencode-ai/core/project"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -52,7 +52,9 @@ const locations = makeGlobalNode({
get: (id) => Effect.succeed(id === info.id ? info : undefined),
list: () => Effect.succeed([info]),
}),
Layer.mock(Plugin.Service, { awaitActivation: Effect.void }),
Layer.succeed(PluginSupervisor.Service, {
awaitActivation: Effect.void,
}),
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
),
),
+33 -2
View File
@@ -1,5 +1,13 @@
import { beforeEach, expect } from "bun:test"
import { AIError, LLMClient, LLMEvent, LanguageModel, TransportError, type LLMRequest } from "@opencode-ai/ai"
import {
AIError,
LLMClient,
LLMEvent,
LanguageModel,
SystemPart,
TransportError,
type LLMRequest,
} from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
@@ -122,7 +130,7 @@ const it = testEffect(
Catalog.node.replace(catalog),
SessionRunnerModel.node.replace(models),
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
PluginSupervisor.node.replace(Layer.empty),
PluginSupervisor.node.replace(Layer.mock(PluginSupervisor.Service, { awaitActivation: Effect.void })),
],
),
)
@@ -499,6 +507,29 @@ it.effect("does not rename after a failed title stream", () =>
}),
)
it.effect("keeps session context hooks away from title requests", () =>
Effect.gen(function* () {
yield* enableTitleAgent
// Context hooks shape the agent conversation; title generation is not part of
// it, so it opts out and the transcript passes through unchanged.
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.system.push(SystemPart.make("Keep titles in sentence case."))
}),
)
const sessionID = Session.ID.make("ses_title_context_hook")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Hook this title request")
const title = yield* SessionTitle.Service
yield* title.generate(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."])
}),
)
it.effect("preserves a manual rename completed while generation is in flight", () =>
Effect.gen(function* () {
yield* enableTitleAgent
+145 -1
View File
@@ -3,11 +3,17 @@ import { Agent } from "@opencode-ai/core/agent"
import type { Permission } from "@opencode-ai/core/permission"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Image } from "@opencode-ai/core/image"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { LanguageModel } from "@opencode-ai/ai"
import { route } from "@opencode-ai/ai/protocols/openai-chat"
import { State } from "@opencode-ai/core/state"
import { Tool } from "@opencode-ai/core/tool"
import type { Info } from "@opencode-ai/schema/tool"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { codeModeListings, executeTool, toolDefinitions } from "./lib/tool"
import { Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { TestClock } from "effect/testing"
@@ -36,7 +42,9 @@ const imageStore = Layer.mock(Image.Service, {
})
},
})
const registryLayer = AppNodeBuilder.build(Tool.node, [Image.node.replace(imageStore)])
const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node, SessionModelRequest.node]), [
Image.node.replace(imageStore),
])
const it = testEffect(registryLayer)
const identity = {
agent: Agent.ID.make("build"),
@@ -84,6 +92,116 @@ describe("Tool", () => {
}),
)
it.effect("repairs names and inputs before lookup using the captured request tool set", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const hooks = yield* PluginHooks.Service
yield* transform(service, { echo: constant("captured"), hidden: make() }, { codemode: false })
const snapshot = yield* service.snapshot()
const modelRequests = yield* SessionModelRequest.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
const echo = event.tools.echo
if (!echo) throw new Error("Expected echo definition")
event.tools.alias = echo
delete event.tools.echo
delete event.tools.hidden
}),
)
const prepared = yield* modelRequests.prepare({
scope: {
session: Schema.decodeUnknownSync(Session.Info)({
id: sessionID,
projectID: "project",
location: { directory: "/test" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}),
agentID: identity.agent,
model: SessionRunnerModel.resolved(LanguageModel.make({ id: "test", provider: "test", route }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
}),
tools: snapshot,
},
transcript: { system: [], messages: [] },
})
expect(prepared.request.tools.map((tool) => tool.name)).toEqual(["execute", "alias"])
yield* transform(service, { echo: constant("new") }, { codemode: false })
const before: string[] = []
const after: string[] = []
yield* hooks.register("tool", "execute.before", (event) =>
Effect.sync(() => {
expect(event).not.toHaveProperty("inputSchema")
before.push(event.tool)
event.tool = event.tool === "typo" ? "alias" : event.tool
event.input = { text: "corrected" }
}),
)
yield* hooks.register("tool", "execute.after", (event) =>
Effect.sync(() => {
after.push(event.tool)
expect(event.input).toEqual({ text: "corrected" })
}),
)
expect((yield* prepared.executeTool(call("typo"))).output).toEqual({ text: "captured" })
expect(before).toEqual(["typo"])
expect(after).toEqual(["echo"])
expect(yield* prepared.executeTool(call("hidden")).pipe(Effect.flip)).toMatchObject({
message: "Tool is not available for this request: hidden",
})
expect(yield* prepared.executeTool(call("echo")).pipe(Effect.flip)).toMatchObject({
message: "Tool is not available for this request: echo",
})
expect(yield* prepared.executeTool(call("missing")).pipe(Effect.flip)).toMatchObject({
message: "Unknown tool: missing",
})
expect(before).toEqual(["typo", "hidden", "echo", "missing"])
expect(after).toEqual(["echo"])
}),
)
it.effect("hooks execute and known Code Mode calls once but leaves unknown interpreter paths unchanged", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const hooks = yield* PluginHooks.Service
yield* transform(service, { echo: make() })
const seen: string[] = []
yield* hooks.register("tool", "execute.before", (event) =>
Effect.sync(() => {
seen.push(event.tool)
expect(event).not.toHaveProperty("inputSchema")
if (event.tool === "run_code") event.tool = "execute"
}),
)
const snapshot = yield* service.snapshot()
const known = yield* snapshot.execute({
...call("run_code"),
call: {
type: "tool-call",
id: "known",
name: "run_code",
input: { code: 'return await tools.echo({ text: "hello" })' },
},
})
expect(known.output).toMatchObject({ output: '{\n "text": "hello"\n}' })
expect(seen).toEqual(["run_code", "echo"])
const unknown = yield* snapshot.execute({
...call("execute"),
call: {
type: "tool-call",
id: "unknown",
name: "execute",
input: { code: "return await tools.missing({})" },
},
})
expect(unknown.output).toMatchObject({ error: true })
expect(seen).toEqual(["run_code", "echo", "execute"])
}),
)
it.effect("replays mutations on refreshed sources and restores tools on disposal and scope cleanup", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
@@ -866,6 +984,32 @@ describe("Tool", () => {
}),
)
it.effect("normalizes image content added by an after hook", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const hooks = yield* PluginHooks.Service
yield* transform(service, { hooked: constant("original") }, { codemode: false })
yield* hooks.register("tool", "execute.after", (event) =>
Effect.sync(() => {
if (event.status !== "completed") return
event.result = {
...event.result,
content: [{ type: "file", uri: "data:image/png;base64,aW1hZ2U=", mime: "image/png", name: "hook.png" }],
}
}),
)
expect((yield* executeTool(service, call("hooked"))).content).toEqual([
{
type: "file",
uri: "data:image/jpeg;base64,aW1hZ2Ugbm9ybWFsaXplZA==",
mime: "image/jpeg",
name: "hook.png",
},
])
}),
)
it.effect("publishes progress metadata unchanged", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
+55 -6
View File
@@ -30,9 +30,8 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginRuntimeProvider } from "@opencode-ai/core/plugin/runtime-provider"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
@@ -127,8 +126,11 @@ const executionNode = makeGlobalNode({
})
const shellPluginSupervisor = makeLocationNode({
name: "test/shell-plugins",
layer: Layer.effectDiscard(registerToolPlugin(ShellTool.Plugin)),
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ awaitActivation: Effect.void }))),
),
deps: [
Config.node,
Environment.node,
@@ -147,7 +149,7 @@ const nodes = LayerNode.group([
Job.node,
Session.node,
SessionExecution.node,
PluginRuntimeProvider.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
filesystem,
FSUtil.node,
@@ -186,6 +188,9 @@ const mixedOutputCommand = isWindows
? "[Console]::Out.Write('stdout'); Start-Sleep -Milliseconds 50; [Console]::Error.Write('stderr'); Start-Sleep -Milliseconds 100"
: "printf stdout; sleep 0.05; printf stderr >&2"
const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
const timeoutOutputCommand = isWindows
? "[Console]::Out.Write('before timeout'); Start-Sleep -Seconds 60"
: "printf 'before timeout'; sleep 60"
const bodyExitCommand = isWindows
? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7"
: "printf body && exit 7"
@@ -214,7 +219,7 @@ const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface
const locations = yield* LocationServiceMap.Service
const locationLayer = locations.get(location)
return yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const plugins = yield* PluginSupervisor.Service
yield* plugins.awaitActivation
const registry = yield* Tool.Service
return yield* body(registry)
@@ -1290,6 +1295,50 @@ describe("ShellTool", () => {
{ timeout: 15_000 },
)
it.live(
"authorizes the hook-edited command and workdir and reports its timeout",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const timeout = isWindows ? 3_000 : 500
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
yield* hooks.register("shell", "create.before", (invocation) =>
Effect.sync(() => {
invocation.command = timeoutOutputCommand
invocation.cwd = tmp.path
invocation.timeout = timeout
}),
)
return yield* executeTool(registry, call({ command: helloCommand, workdir: "missing", timeout: 60_000 }))
}),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
expect(settled.metadata).not.toHaveProperty("exit")
const content = settled.content?.[0]
expect(content?.type).toBe("text")
if (content?.type !== "text") throw new Error("Expected text content")
expect(content.text).toContain("before timeout")
expect(content.text).toContain(`Command exceeded timeout of ${timeout} ms.`)
expect(settled.content?.[1]).toMatchObject(Expected.text(expect.stringContaining("Command timed out")))
expect(assertions.map((input) => input.action)).toEqual(["shell"])
expect(assertions[0]?.resources).toEqual(
isWindows ? [idleCommand] : ["printf 'before timeout'", idleCommand],
)
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
)
it.live("returns the shell id for a background command", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
+11 -6
View File
@@ -29,9 +29,7 @@ import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginRuntimeProvider } from "@opencode-ai/core/plugin/runtime-provider"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Permission } from "@opencode-ai/core/permission"
import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
@@ -106,8 +104,13 @@ const executionNode = makeGlobalNode({
})
const subagentPluginSupervisor = makeLocationNode({
name: "test/subagent-plugins",
layer: Layer.effectDiscard(registerToolPlugin(SubagentTool.Plugin)),
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(SubagentTool.Plugin).pipe(
Effect.as(PluginSupervisor.Service.of({ awaitActivation: Effect.void })),
),
),
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
})
@@ -117,7 +120,7 @@ const nodes = LayerNode.group([
Job.node,
Session.node,
SessionExecution.node,
PluginRuntimeProvider.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
])
const replacements = [
@@ -154,7 +157,9 @@ const completionIt = testEffect(
const withSubagent = (location: Location.Ref) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
yield* Plugin.Service.use((plugins) => plugins.awaitActivation).pipe(Effect.provide(locations.get(location)))
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
Effect.provide(locations.get(location)),
)
yield* Agent.Service.use((agents) =>
agents.transform((draft) => {
// The caller identity used by executeTool; subagent permission asserts against it.
+2 -1
View File
@@ -5,7 +5,8 @@
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit -p tsconfig.tests.json",
"build": "tsc -p tsconfig.build.json"
},
"exports": {
+1 -1
View File
@@ -204,7 +204,7 @@ function compileEndpoint(endpoint: HttpApiEndpoint.Top) {
/**
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
* Core plugin runtime can run it unchanged.
* loader (`Plugin` / `PluginSupervisor`) can run it unchanged.
*
* Hook registrations created during the async `setup` attach to the plugin's
* scope, so unloading the plugin disposes them. The captured fiber context
@@ -0,0 +1,72 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Command } from "@opencode-ai/schema/command"
import { Connection } from "@opencode-ai/schema/connection"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Location } from "@opencode-ai/schema/location"
import { Mcp } from "@opencode-ai/schema/mcp"
import { Model } from "@opencode-ai/schema/model"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { Provider } from "@opencode-ai/schema/provider"
import { Reference } from "@opencode-ai/schema/reference"
import { Rpc } from "@opencode-ai/schema/rpc"
import { Skill } from "@opencode-ai/schema/skill"
import { Vcs } from "@opencode-ai/schema/vcs"
import { WebSearch } from "@opencode-ai/schema/websearch"
const Plugin = await import("../src/effect/index")
const PromisePlugin = await import("../src/promise/index")
const TuiPlugin = await import("../src/tui/index")
const PromiseEvent = await import("../src/promise/event")
const PromiseRpc = await import("../src/promise/rpc")
test.each([
["effect", Plugin],
["promise", PromisePlugin],
])("%s entrypoint exposes its canonical Schema contracts", (_name, entrypoint) => {
expect(entrypoint.Agent).toBe(Agent)
expect(entrypoint.Command).toBe(Command)
expect(entrypoint.Connection).toBe(Connection)
expect(entrypoint.Credential).toBe(Credential)
expect(entrypoint.Integration).toBe(Integration)
expect(entrypoint.Location).toBe(Location)
expect(entrypoint.Mcp).toBe(Mcp)
expect(entrypoint.Model).toBe(Model)
expect(entrypoint.PersistentPty).toBe(PersistentPty)
expect(entrypoint.Provider).toBe(Provider)
expect(entrypoint.Reference).toBe(Reference)
expect(entrypoint.Rpc).toBe(Rpc)
expect(entrypoint.Skill).toBe(Skill)
expect(entrypoint.Vcs).toBe(Vcs)
expect(entrypoint.WebSearch).toBe(WebSearch)
expect(Object.keys(entrypoint).sort()).toEqual([
"Agent",
"Command",
"Connection",
"Credential",
"Integration",
"Location",
"Mcp",
"Model",
"PersistentPty",
"Plugin",
"Provider",
"Reference",
"Rpc",
"Skill",
"Vcs",
"WebSearch",
])
})
test("tui entrypoint exposes the plugin definition", () => {
const plugin = TuiPlugin.Plugin.define({ id: "demo", setup() {} })
expect(plugin.id).toBe("demo")
})
test("Promise domain modules do not expose Effect adapter internals", () => {
expect(Object.keys(PromiseEvent)).toEqual([])
expect(Object.keys(PromiseRpc)).toEqual([])
})
+171
View File
@@ -0,0 +1,171 @@
import type { OpenCodeClient, RpcApi } from "@opencode-ai/client/effect"
import type { RpcHandlers, RpcRegistration } from "@opencode-ai/plugin/effect/rpc"
import type { Plugin } from "@opencode-ai/plugin/effect"
import { Rpc } from "@opencode-ai/plugin/rpc"
import { Effect, Schema, Stream } from "effect"
import type { Scope } from "effect"
import { Acme, EffectAcme } from "./rpc.fixture.js"
import type { Assert, Equal } from "./rpc.fixture.js"
declare const client: { readonly rpc: RpcApi<"transport-failure"> }
declare const ctx: Plugin.Context
declare const actualClient: OpenCodeClient
declare const name: "updated" | "progress"
declare const emission: Rpc.EventInput<typeof Acme>
const acme = client.rpc(Acme)
const search = acme.search({ query: "hello" })
const count = acme.count({ count: "42" })
const codec = acme.codec({ count: "42" })
const raw = acme.raw({ value: "hello" })
const ping = acme.ping()
const updates = acme.events.subscribe("updated")
const actualCall = actualClient.rpc(Acme).codec({ count: "42" })
const effectCall = actualClient.rpc(EffectAcme).codec({ count: "42" })
const effectUpdates = actualClient.rpc(EffectAcme).events.subscribe("progress")
const localCall = ctx.rpc(Acme).search({ query: "hello" })
export type Checks = [
Assert<Equal<Effect.Success<typeof search>, { text: string }>>,
Assert<
Equal<
Effect.Error<typeof search>,
| "transport-failure"
| { readonly type: "not_found"; readonly message: string; readonly data: { query: string; attempts: number } }
| { readonly type: "unavailable"; readonly message: string; readonly data?: undefined }
>
>,
Assert<Equal<Effect.Services<typeof search>, never>>,
Assert<Equal<Effect.Success<typeof count>, string>>,
Assert<Equal<Effect.Success<typeof codec>, number>>,
Assert<Equal<Effect.Success<typeof raw>, unknown>>,
Assert<Equal<Effect.Success<typeof ping>, null>>,
Assert<Equal<Stream.Success<typeof updates>, Rpc.EventPayload<typeof Acme, "updated">>>,
Assert<Equal<Stream.Error<typeof updates>, "transport-failure">>,
Assert<Equal<Stream.Services<typeof updates>, never>>,
Assert<Equal<Effect.Success<typeof actualCall>, number>>,
Assert<Equal<Effect.Services<typeof actualCall>, never>>,
Assert<Equal<Effect.Success<typeof effectCall>, number>>,
Assert<Equal<Extract<Effect.Error<typeof effectCall>, Schema.SchemaError>, Schema.SchemaError>>,
Assert<Equal<Extract<Stream.Error<typeof effectUpdates>, Schema.SchemaError>, Schema.SchemaError>>,
Assert<
Equal<
Extract<Effect.Error<typeof effectCall>, { readonly type: "invalid_count" }>,
{ readonly type: "invalid_count"; readonly message: string; readonly data: { readonly count: number } }
>
>,
Assert<
Equal<
Extract<Effect.Error<typeof localCall>, { readonly type: "not_found" }>,
{ readonly type: "not_found"; readonly message: string; readonly data: { query: string; attempts: number } }
>
>,
]
acme.search({ query: "hello" }, { location: { directory: "/project" } })
ctx.rpc(Acme).search({ query: "hello" })
// @ts-expect-error Effect callers supply the schema's accepted input representation too.
acme.count({ count: 42 })
// @ts-expect-error Unknown method names are rejected.
acme.missing()
// @ts-expect-error Plugin handles cannot override their location.
ctx.rpc(Acme).search({ query: "hello" }, { location: { directory: "/other" } })
// @ts-expect-error Effect event clients expose Streams, not callback convenience wrappers.
acme.events.on("updated", () => {})
// @ts-expect-error Only declared local event names can be subscribed to.
acme.events.subscribe("missing")
const handlers: RpcHandlers<typeof Acme> = {
search: (input, context) => {
context.error("not_found", "Missing", { query: input.query, attempts: "1" })
context.error("unavailable", "Unavailable")
return Effect.succeed({ text: input.query })
},
count: (input) => {
input.count satisfies number
return Effect.succeed(input.count)
},
codec: (input) => {
input.count satisfies number
return Effect.succeed(input.count)
},
raw: () => Effect.succeed(1),
ping: () => Effect.succeed(null),
}
const registration = ctx.rpc.register(Acme, handlers)
export type RegistrationChecks = [
Assert<Equal<Effect.Success<typeof registration>, RpcRegistration<typeof Acme>>>,
Assert<Equal<Effect.Error<typeof registration>, unknown>>,
Assert<Equal<Effect.Services<typeof registration>, Scope.Scope>>,
]
ctx.rpc.register(Acme, {
...handlers,
search: (input) => {
input.query satisfies string
return Effect.succeed({ text: input.query })
},
})
ctx.rpc.register(Acme, {
...handlers,
search: (input, context) =>
Effect.fail(context.error("not_found", "Missing", { query: input.query, attempts: "1" })),
})
ctx.rpc.register(Acme, {
...handlers,
// @ts-expect-error Error names must be declared by the method.
search: (_input, context) => Effect.fail(context.error("missing", "Missing", {})),
})
ctx.rpc.register(Acme, {
...handlers,
search: (input, context) =>
Effect.fail(
context.error("not_found", "Missing", {
query: input.query,
// @ts-expect-error Error data uses the schema's handler-side representation.
attempts: 1,
}),
),
})
// @ts-expect-error Wrong result types cannot widen the shared definition.
ctx.rpc.register(Acme, { ...handlers, search: () => Effect.succeed({ text: 42 }) })
// @ts-expect-error Effect handlers cannot return Promises.
ctx.rpc.register(Acme, { ...handlers, search: async () => ({ text: "hello" }) })
// @ts-expect-error All declared handlers are required.
ctx.rpc.register(Acme, { search: handlers.search })
Effect.gen(function* () {
const active = yield* registration
yield* active.events.emit("updated", { itemID: "123", text: "hello" })
yield* active.events.emit("counted", { count: 42 })
yield* active.events.emit(...emission)
yield* active.dispose
// @ts-expect-error Published payloads are inferred from the selected event schema.
yield* active.events.emit("progress", { percent: "50" })
// @ts-expect-error Only local event names are accepted for publishing.
yield* active.events.emit("rpc.acme.updated", { itemID: "123", text: "hello" })
// @ts-expect-error A union name must stay correlated with its publishing payload.
yield* active.events.emit(name, { percent: 50 })
})
Stream.map(updates, (event) => {
event.type satisfies "rpc.acme.updated"
event.location.directory satisfies string
return event.data.text satisfies string
})
// @ts-expect-error Effect custom event data must also be an object.
Rpc.define({ id: "invalid-event", methods: {}, events: { updated: { schema: Schema.String } } })
Rpc.define({
id: "invalid-array-event",
methods: {},
// @ts-expect-error Effect custom event data cannot be an array.
events: { updated: { schema: Schema.Array(Schema.String) } },
})
+210
View File
@@ -0,0 +1,210 @@
import { OpenCode } from "@opencode-ai/client"
import type { RpcCallOptions, RpcEventPayload } from "@opencode-ai/client"
import { Rpc } from "@opencode-ai/plugin/rpc"
import type { RpcHandlers } from "@opencode-ai/plugin/promise/rpc"
import type { Plugin } from "@opencode-ai/plugin"
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { z } from "zod"
import { Acme, EffectAcme } from "./rpc.fixture.js"
import type { Assert, Equal } from "./rpc.fixture.js"
const client = OpenCode.make({ baseUrl: "http://localhost" })
declare const ctx: Plugin.Context
const acme = client.rpc(Acme)
const search = acme.search({ query: "hello" })
const count = acme.count({ count: "42" })
const codec = acme.codec({ count: "42" })
const raw = acme.raw({ value: "hello" })
const ping = acme.ping()
export type Checks = [
Assert<Equal<typeof Acme.id, "acme">>,
Assert<Equal<keyof typeof Acme.methods, "search" | "count" | "codec" | "raw" | "ping">>,
Assert<Equal<typeof search, Promise<{ text: string }>>>,
Assert<Equal<typeof count, Promise<string>>>,
Assert<Equal<typeof codec, Promise<number>>>,
Assert<Equal<typeof raw, Promise<unknown>>>,
Assert<Equal<typeof ping, Promise<null>>>,
Assert<Equal<Rpc.Input<typeof Acme.methods.count.input>, { count: string }>>,
Assert<Equal<Rpc.Output<typeof Acme.methods.count.input>, { count: number }>>,
Assert<Equal<Rpc.HandlerOutput<typeof Acme.methods.count.output>, number>>,
Assert<Equal<Rpc.HandlerOutput<typeof Acme.methods.codec.output>, number>>,
Assert<Equal<Rpc.EventPayload<typeof Acme, "updated">["type"], "rpc.acme.updated">>,
Assert<Equal<RpcEventPayload<typeof Acme, "updated">["location"], { directory: string; workspaceID?: string }>>,
Assert<Equal<Rpc.Input<StandardSchemaV1<string, number>>, string>>,
Assert<Equal<Rpc.Output<StandardSchemaV1<string, number>>, number>>,
Assert<Equal<Rpc.HandlerOutput<StandardSchemaV1<string, number>>, string>>,
]
await acme.search({ query: "hello" }, { location: { directory: "/project", workspace: "workspace" } })
await acme.search({ query: "hello" }, { signal: new AbortController().signal, headers: { "x-test": "yes" } })
await acme.ping(undefined, { location: { directory: "/project" } })
await ctx.rpc(Acme).search({ query: "hello" }, { signal: new AbortController().signal })
// @ts-expect-error Native event subscriptions share base headers, not subscriber overrides.
client.event.subscribe({ headers: { authorization: "override" } })
// @ts-expect-error Query must be a string.
await acme.search({ query: 1 })
// @ts-expect-error Required method inputs cannot be omitted.
await acme.search()
// @ts-expect-error Callers supply the input representation, not the parsed value.
await acme.count({ count: 42 })
// @ts-expect-error Standard Schema callers supply the accepted input representation.
await acme.codec({ count: 42 })
// @ts-expect-error Only declared methods are callable.
await acme.missing({})
// @ts-expect-error Location is call metadata, not injected into the declared input.
await acme.search({ query: "hello", location: { directory: "/project" } })
// @ts-expect-error Plugin handles cannot select another location.
await ctx.rpc(Acme).search({ query: "hello" }, { location: { directory: "/other" } })
// @ts-expect-error Plugin handles cannot use headers to override their location either.
await ctx.rpc(Acme).search({ query: "hello" }, { headers: { "x-opencode-directory": "/other" } })
declare const remoteOptions: RpcCallOptions
// @ts-expect-error Passing options through a variable must not enable local routing overrides.
await ctx.rpc(Acme).search({ query: "hello" }, remoteOptions)
const handlers: RpcHandlers<typeof Acme> = {
search: async (input, call) => {
input.query satisfies string
call.signal satisfies AbortSignal
if (input.query === "missing")
return call.error("not_found", "Missing", { query: input.query, attempts: "1" })
if (input.query === "unavailable") throw call.error("unavailable", "Unavailable")
return { text: input.query }
},
count: async (input) => {
input.count satisfies number
// @ts-expect-error Handlers receive the parsed representation.
input.count satisfies string
return input.count
},
codec: async (input) => {
input.count satisfies number
return input.count
},
raw: async (input) => {
// @ts-expect-error Plain JSON Schema does not infer an input shape.
input.value
return 1
},
ping: async () => null,
}
// @ts-expect-error Error names must be declared by the method.
handlers.search({ query: "missing" }, { signal: AbortSignal.abort(), error: () => ({ type: "missing" }) })
// @ts-expect-error Promise clients accept portable Standard or JSON schemas, not Effect Schema.
client.rpc(EffectAcme)
// @ts-expect-error Promise plugins cannot register Effect Schema contracts.
await ctx.rpc.register(EffectAcme, { codec: async ({ count }) => count })
const registration = await ctx.rpc.register(Acme, handlers)
await registration.events.emit("updated", { itemID: "123", text: "hello" })
await registration.events.emit("progress", { percent: 50 })
await registration.events.emit("counted", { count: 42 })
await registration.dispose()
await ctx.rpc.register(Acme, {
...handlers,
search: async ({ query }) => {
query satisfies string
return { text: query }
},
})
// @ts-expect-error The definition cannot widen to accommodate an incorrect handler result.
await ctx.rpc.register(Acme, { ...handlers, search: async () => ({ text: 42 }) })
// @ts-expect-error Every declared method must have a handler.
await ctx.rpc.register(Acme, { search: handlers.search })
// @ts-expect-error Additional handlers are not declared by the RPC.
await ctx.rpc.register(Acme, { ...handlers, missing: async () => null })
// @ts-expect-error Promise handlers must not return synchronous values.
await ctx.rpc.register(Acme, { ...handlers, ping: () => null })
// @ts-expect-error Standard Schema output transforms consume their input type.
await ctx.rpc.register(Acme, { ...handlers, count: async () => "42" })
// @ts-expect-error Effect output codecs encode the decoded result type.
await ctx.rpc.register(Acme, { ...handlers, codec: async () => "42" })
// @ts-expect-error Event payloads must match their schema.
await registration.events.emit("updated", { itemID: 123, text: "hello" })
// @ts-expect-error Publishing accepts only declared local event names.
await registration.events.emit("missing", {})
// @ts-expect-error Publishing applies the output schema, rather than accepting its transformed result.
await registration.events.emit("counted", { count: "42" })
const unsubscribe = acme.events.on("updated", (event) => {
event.type satisfies "rpc.acme.updated"
event.data.text satisfies string
event.location.directory satisfies string
// @ts-expect-error Payloads are selected by the event name.
event.data.percent
})
unsubscribe satisfies () => void
declare const withoutLocation: Omit<RpcEventPayload<typeof Acme, "updated">, "location">
// @ts-expect-error Custom events always carry their emitting location.
withoutLocation satisfies RpcEventPayload<typeof Acme, "updated">
for await (const event of acme.events.subscribe("counted")) {
event.data.text satisfies string
}
declare const name: "updated" | "progress"
// @ts-expect-error A union name cannot publish a payload matching only one possible event.
await registration.events.emit(name, { percent: 50 })
declare const emission: Rpc.EventInput<typeof Acme>
await registration.events.emit(...emission)
for await (const event of acme.events.subscribe(name)) {
if (event.type === "rpc.acme.updated") {
event.data.text satisfies string
continue
}
event.data.percent satisfies number
}
// @ts-expect-error Subscriptions use local names, not fully prefixed wire types.
acme.events.subscribe("rpc.acme.updated")
// @ts-expect-error Unknown event names are rejected by the convenience wrapper too.
acme.events.on("missing", () => {})
// @ts-expect-error Event subscriptions do not accept per-subscriber headers.
acme.events.subscribe("updated", { headers: { "x-test": "yes" } })
// @ts-expect-error Event subscriptions are not location-filtered externally.
acme.events.on("updated", () => {}, { location: { directory: "/project" } })
// @ts-expect-error Every method requires an output schema.
Rpc.define({ id: "invalid", methods: { search: { input: Acme.methods.search.input } }, events: {} })
Rpc.define({
id: "invalid-error",
methods: {
search: {
input: z.string(),
output: z.string(),
// @ts-expect-error Error names beginning with rpc. are reserved for framework failures.
errors: { "rpc.internal": z.undefined() },
},
},
events: {},
})
// @ts-expect-error The subclient's events member is reserved, not an RPC method.
Rpc.define({ id: "invalid", methods: { events: Acme.methods.search }, events: {} })
// @ts-expect-error Custom event data must be an object.
Rpc.define({ id: "invalid-event", methods: {}, events: { updated: { schema: z.string() } } })
// @ts-expect-error Custom event data cannot be an array.
Rpc.define({ id: "invalid-array-event", methods: {}, events: { updated: { schema: z.array(z.string()) } } })
// @ts-expect-error Plain JSON Schema events must declare an object root.
Rpc.define({ id: "invalid-json-event", methods: {}, events: { updated: { schema: { type: "string" } } } })
const LocationInput = Rpc.define({
id: "location-input",
methods: {
echo: {
input: z.object({ location: z.string() }),
output: z.object({ location: z.string() }),
},
},
events: {},
})
await client.rpc(LocationInput).echo({ location: "a plugin-defined field" }, { location: { directory: "/project" } })
+54
View File
@@ -0,0 +1,54 @@
import { Rpc } from "@opencode-ai/plugin/rpc"
import { Schema } from "effect"
import type { Types } from "effect"
import { z } from "zod"
export const Acme = Rpc.define({
id: "acme",
methods: {
search: {
input: z.object({ query: z.string() }),
output: z.object({ text: z.string() }),
errors: {
not_found: z.object({ query: z.string(), attempts: z.string().transform(Number) }),
unavailable: z.undefined(),
},
},
count: {
input: z.object({ count: z.string().transform(Number) }),
output: z.number().transform(String),
},
codec: {
input: z.object({ count: z.string().transform(Number) }),
output: z.number(),
},
raw: {
input: { type: "object", properties: { value: { type: "string" } }, required: ["value"] },
output: { type: "integer" },
},
ping: {
input: z.undefined(),
output: z.null(),
},
},
events: {
updated: { schema: z.object({ itemID: z.string(), text: z.string() }) },
progress: { schema: z.object({ percent: z.number() }) },
counted: { schema: z.object({ count: z.number() }).transform(({ count }) => ({ text: String(count) })) },
},
})
export const EffectAcme = Rpc.define({
id: "effect-acme",
methods: {
codec: {
input: Schema.Struct({ count: Schema.FiniteFromString }),
output: Schema.FiniteFromString,
errors: { invalid_count: Schema.Struct({ count: Schema.FiniteFromString }) },
},
},
events: { progress: { schema: Schema.Struct({ percent: Schema.Number }) } },
})
export type Equal<A, B> = Types.Equals<A, B>
export type Assert<T extends true> = T
+66
View File
@@ -0,0 +1,66 @@
import { expect, test } from "bun:test"
import { Rpc } from "@opencode-ai/plugin/rpc"
import { fileURLToPath } from "node:url"
import { Acme } from "./rpc.fixture.js"
test("definitions preserve their schemas and ID without registering anything", () => {
expect(Rpc.define(Acme)).toBe(Acme)
expect(Acme.id).toBe("acme")
expect(Object.keys(Acme.events)).toEqual(["updated", "progress", "counted"])
})
test("defining an RPC contract does not invoke its schema parser", () => {
const schema = {
"~standard": {
version: 1 as const,
vendor: "test",
validate: () => {
throw new Error("Definition must not parse values")
},
},
}
const definition = Rpc.define({
id: "portable",
methods: { echo: { input: schema, output: schema, errors: { rejected: schema } } },
events: { updated: { schema } },
})
expect(definition.methods.echo.input).toBe(schema)
expect(definition.methods.echo.output).toBe(schema)
expect(definition.methods.echo.errors.rejected).toBe(schema)
expect(definition.events.updated.schema).toBe(schema)
})
test("framework RPC error names are reserved", () => {
const schema = { type: "null" }
const errors = Object.fromEntries([["rpc.internal", schema]])
expect(() =>
Rpc.define({ id: "reserved", methods: { call: { input: schema, output: schema, errors } }, events: {} }),
).toThrow('RPC error names starting with "rpc." are reserved: rpc.internal')
})
test("the shared definition entrypoint bundles without Effect or host runtime dependencies", async () => {
const inputs = new Set<string>()
const result = await Bun.build({
entrypoints: [fileURLToPath(import.meta.resolve("@opencode-ai/plugin/rpc"))],
target: "browser",
plugins: [
{
name: "rpc-import-boundary",
setup(build) {
build.onLoad({ filter: /.*/ }, (args) => {
inputs.add(args.path)
return undefined
})
},
},
],
})
expect(result.success).toBe(true)
expect([...inputs].sort((a, b) => a.localeCompare(b))).toEqual(
[import.meta.resolve("@opencode-ai/plugin/rpc"), import.meta.resolve("@opencode-ai/schema/rpc")]
.map((url) => fileURLToPath(url))
.sort((a, b) => a.localeCompare(b)),
)
})
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["src", "test/**/*.types.ts"]
}
+29
View File
@@ -1,4 +1,5 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { isOpenCodeEvent, OpenCodeEvent, type OpenCodeEventEncoded } from "../src/groups/event.js"
type JsonShape<Value> = Value extends string | number | boolean | null
@@ -21,14 +22,42 @@ type JsonShape<Value> = Value extends string | number | boolean | null
// requiring every runtime event shape to fit its encoded wire contract.
const wireReady: [JsonShape<OpenCodeEvent>] extends [JsonShape<OpenCodeEventEncoded>] ? true : false = true
// This fails to compile if the dynamic RPC branch absorbs native discriminants.
const nativeDataNarrows = (event: OpenCodeEvent) => {
if (event.type !== "session.created") return
const sessionID: string = event.data.sessionID
return sessionID
}
test("classifies public events by type", () => {
expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true)
expect(isOpenCodeEvent({ type: "mcp.status.changed" })).toBe(true)
expect(isOpenCodeEvent({ type: "mcp.resources.changed" })).toBe(true)
expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false)
expect(isOpenCodeEvent({ type: "rpc.acme.updated" })).toBe(true)
expect(isOpenCodeEvent({ type: "acme.updated" })).toBe(false)
})
test("decodes direct plugin RPC events", () => {
const event = {
id: "evt_rpc",
created: 1,
type: "rpc.acme.updated",
location: { directory: "/project" },
data: { itemID: "item-1", text: "hello" },
}
expect(Schema.decodeUnknownSync(OpenCodeEvent)(event)).toMatchObject(event)
expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, location: undefined })).toThrow()
expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, type: "acme.updated" })).toThrow()
expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, data: "value" })).toThrow()
expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, data: [] })).toThrow()
expect(() => Schema.decodeUnknownSync(OpenCodeEvent)({ ...event, data: null })).toThrow()
})
test("keeps native event data discriminated by type", () => {
expect(nativeDataNarrows).toBeFunction()
})
test("keeps public event runtime values within the encoded contract", () => {
expect(wireReady).toBe(true)
})
+64
View File
@@ -0,0 +1,64 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { OpenApi } from "effect/unstable/httpapi"
import { ClientApi, groupNames } from "../src/client.js"
import { RpcError, RpcInternalError } from "../src/errors.js"
import { RpcInput, RpcOutput } from "../src/groups/rpc.js"
test("RPC wrappers preserve JSON primitives and omit undefined fields", () => {
expect(Schema.encodeSync(RpcInput)({ input: undefined })).toEqual({})
expect(Schema.encodeSync(RpcOutput)({})).toEqual({})
expect(Schema.decodeUnknownSync(RpcInput)({})).toEqual({})
expect(Schema.decodeUnknownSync(RpcOutput)({})).toEqual({})
for (const value of [null, false, 123, "text", [1, 2], { location: "ordinary payload" }]) {
expect(Schema.decodeUnknownSync(RpcInput)({ input: value })).toEqual({ input: value })
expect(Schema.decodeUnknownSync(RpcOutput)({ output: value })).toEqual({ output: value })
}
})
test("RPC errors use the standard transport wrapper", () => {
expect(Schema.encodeSync(RpcError)(new RpcError({ type: "not_found", message: "Missing", data: { id: "1" } }))).toEqual(
{
_tag: "RpcError",
type: "not_found",
message: "Missing",
data: { id: "1" },
},
)
expect(Schema.encodeSync(RpcError)(new RpcError({ type: "internal", message: "Failed" }))).toEqual({
_tag: "RpcError",
type: "internal",
message: "Failed",
})
expect(
Schema.decodeUnknownSync(RpcError)({ _tag: "RpcError", type: "not_found", message: "Missing", data: {} }),
).toBeInstanceOf(RpcError)
expect(
Schema.encodeSync(RpcInternalError)(new RpcInternalError({ type: "rpc.internal", message: "Failed" })),
).toEqual({ _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" })
expect(
Schema.encodeSync(RpcInternalError)(new RpcInternalError({ type: "rpc.invalid_output", message: "Invalid" })),
).toEqual({ _tag: "RpcInternalError", type: "rpc.invalid_output", message: "Invalid" })
})
test("exposes one generic RPC operation with location routing and ordinary transport errors", () => {
expect(groupNames["server.rpc"]).toBe("rpc")
expect(Object.keys(ClientApi.groups["server.rpc"].endpoints)).toEqual(["rpc.call"])
const document = OpenApi.fromApi(ClientApi)
expect(Object.keys(document.paths).filter((path) => path.startsWith("/api/rpc/"))).toEqual([
"/api/rpc/{rpcID}/{method}",
])
const operation = document.paths["/api/rpc/{rpcID}/{method}"]?.post
expect(operation?.operationId).toBe("v2.rpc.call")
expect(operation?.parameters).toContainEqual(
expect.objectContaining({ name: "rpcID", in: "path", required: true }),
)
expect(operation?.parameters).toContainEqual(expect.objectContaining({ name: "method", in: "path", required: true }))
expect(operation?.parameters).toContainEqual(
expect.objectContaining({ name: "location", in: "query", style: "deepObject", explode: true }),
)
expect(operation?.responses).toHaveProperty("200")
expect(operation?.responses).toHaveProperty("400")
expect(operation?.responses).toHaveProperty("401")
expect(operation?.responses).toHaveProperty("500")
})
@@ -18,6 +18,7 @@ import { EventManifest } from "../src/event-manifest.js"
import { FileSystemV1 } from "../src/filesystem-v1.js"
import { IdeEvent } from "../src/ide-event.js"
import { McpEvent } from "../src/mcp-event.js"
import { Plugin } from "../src/plugin.js"
import { SessionEvent } from "../src/session-event.js"
import { SessionID } from "../src/session-id.js"
import { SessionMessage } from "../src/session-message.js"
@@ -37,6 +38,7 @@ describe("public event manifest", () => {
Array.from(new Set(EventManifest.Definitions.map((definition) => definition.type))),
)
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
expect(EventManifest.Latest.get("plugin.updated")).toBe(Plugin.Event.Updated)
expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged)
expect(EventManifest.Server.get("mcp.resources.changed")).toBe(McpEvent.ResourcesChanged)
expect(EventManifest.Server.get("session.created")).toBe(SessionEvent.Created)
@@ -46,6 +48,7 @@ describe("public event manifest", () => {
expect(EventManifest.Server.has("question.asked")).toBe(false)
expect(EventManifest.Server.has("question.replied")).toBe(false)
expect(EventManifest.Server.has("question.rejected")).toBe(false)
expect(EventManifest.Server.has("rpc.acme.updated")).toBe(false)
expect(Agent.Event.Updated.durable).toBeUndefined()
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
})
@@ -69,6 +72,7 @@ describe("public event manifest", () => {
expect(PersistentPty.Event.Definitions).toEqual([PersistentPty.Event.Added, PersistentPty.Event.Removed])
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated])
expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.ResourcesChanged, McpEvent.StatusChanged])
expect(EventManifest.Latest.has("mcp.browser.open.failed")).toBe(false)
expect(EventManifest.Latest.has("ide.installed")).toBe(false)
+39
View File
@@ -0,0 +1,39 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { Plugin } from "../src/plugin.js"
test("embeds plugin state with a status discriminator", () => {
const decode = Schema.decodeUnknownSync(Plugin.Info)
const source = { type: "package" as const, target: "acme", version: "1.2.3" }
const features = { server: true as const }
expect(decode({ id: "acme", source, features, state: { status: "active" } })).toEqual({
id: Plugin.ID.make("acme"),
source,
features,
state: { status: "active" },
})
expect(decode({ source, features, state: { status: "failed", error: "broken" } })).toEqual({
source,
features,
state: { status: "failed", error: "broken" },
})
expect(decode({ source: { ...source, outdated: true }, features, state: { status: "active" } }).source).toEqual({
...source,
outdated: true,
})
})
test("plugin failure references round trip and absent references stay omitted", () => {
const codec = Schema.fromJsonString(Plugin.State)
const failed = { status: "failed", error: "Plugin failed to load", ref: "err_a1b2c3d4" } as const
expect(Schema.decodeUnknownSync(codec)(Schema.encodeSync(codec)(failed))).toEqual(failed)
expect(Schema.encodeSync(Plugin.State)({ ...failed, ref: undefined })).toEqual({
status: "failed",
error: failed.error,
})
expect(Schema.decodeUnknownSync(Plugin.State)({ status: "failed", error: failed.error })).toMatchObject({
status: "failed",
error: failed.error,
})
})

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