mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 22:46:20 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c806503694 | ||
|
|
fd73a85de4 | ||
|
|
e2e82f18e2 | ||
|
|
91cdb182f0 | ||
|
|
316f7925d3 |
@@ -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"])
|
||||
})
|
||||
})
|
||||
@@ -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" },
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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("")
|
||||
})
|
||||
@@ -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"] })
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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") {}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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))),
|
||||
)
|
||||
}
|
||||
@@ -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 }),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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) })),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
}),
|
||||
|
||||
@@ -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" })
|
||||
}
|
||||
@@ -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([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
@@ -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
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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!" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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) } },
|
||||
})
|
||||
@@ -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" } })
|
||||
@@ -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
|
||||
@@ -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)),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src", "test/**/*.types.ts"]
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user