fix(plugin): reload late SDK plugins (#35576)

This commit is contained in:
Kit Langton
2026-07-06 13:36:19 -04:00
committed by GitHub
parent 06dcf3f221
commit 2830176972
26 changed files with 1181 additions and 916 deletions
+25 -2
View File
@@ -111,13 +111,26 @@ export type LogItem = Payload | EventLog.Synced
export const isSynced = (item: LogItem): item is EventLog.Synced => item.type === "log.synced"
export type SubscribePayload<D extends readonly Definition[]> = D[number] extends infer Item
? Item extends Definition
? Payload<Item>
: never
: never
export interface Subscribe {
<D extends Definition>(definition: D): Stream.Stream<Payload<D>>
<const D extends readonly [Definition, ...Definition[]]>(definitions: D): Stream.Stream<SubscribePayload<D>>
}
const isDefinition = (input: Definition | readonly Definition[]): input is Definition => !Array.isArray(input)
export interface Interface {
readonly publish: <D extends Definition>(
definition: D,
data: Data<D>,
options?: PublishOptions,
) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly subscribe: Subscribe
/**
* Volatile live channel: every event published from now on, nothing before,
* nothing across a disconnect. The only channel that carries non-durable
@@ -562,11 +575,21 @@ export const layerWith = (options?: LayerOptions) =>
),
)
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
const subscribeOne = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
local(Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))).pipe(
Stream.map((event) => event as Payload<D>),
)
function subscribe<D extends Definition>(definition: D): Stream.Stream<Payload<D>>
function subscribe<const D extends readonly [Definition, ...Definition[]]>(
definitions: D,
): Stream.Stream<SubscribePayload<D>>
function subscribe(input: Definition | readonly Definition[]): Stream.Stream<Payload> {
if (isDefinition(input)) return subscribeOne(input)
const types = new Set(input.map((definition) => definition.type))
return streamLive().pipe(Stream.filter((event) => types.has(event.type)))
}
const streamLive = (): Stream.Stream<Payload> => local(Stream.fromPubSub(pubsub.live))
const readAfter = (
+21 -1
View File
@@ -18,6 +18,7 @@ import { ProviderV2 } from "../provider"
import { Reference } from "../reference"
import { AbsolutePath, type DeepMutable } from "../schema"
import { SkillV2 } from "../skill"
import { Tool } from "../tool/tool"
import { Tools } from "../tool/tools"
import { ToolHooks } from "../tool/hooks"
import { WorkspaceV2 } from "../workspace"
@@ -298,7 +299,26 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}),
},
tool: {
register: (input, options) => tools.register(input, options),
transform: (callback) =>
Effect.gen(function* () {
const registrations: Array<{
readonly name: string
readonly tool: Tool.AnyTool
readonly options?: Tool.RegisterOptions
}> = []
yield* Effect.sync(() =>
callback({
add: (name, tool, options) => {
registrations.push({ name, tool, ...(options ? { options } : {}) })
},
}),
)
yield* Effect.forEach(
registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true },
)
}),
execute: {
before: (callback) =>
toolHooks.hook.before((event) => {
+8 -5
View File
@@ -3,6 +3,9 @@ export * as SdkPlugins from "./sdk"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "../effect/app-node"
import { EventV2 } from "../event"
export const Updated = EventV2.ephemeral({ type: "sdk.plugin.updated", schema: {} })
export interface Store {
readonly plugins: Map<string, Plugin>
@@ -16,9 +19,8 @@ const defaultStore = makeStore()
* Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes,
* so `PluginSupervisor` can add them on every Location boot through the ordinary
* generation path that `PluginSupervisor` uses for plugins discovered from
* config. A plugin registered after a Location has booted only
* applies to Locations booted afterward, matching config-plugin timing;
* embedders register at startup before creating Sessions.
* config. Registration publishes an unlocated update so every booted Location
* reloads its plugin generation from the shared store.
*
* The store is shared explicitly between the SDK construction graph and the
* embedded route graph because `LocationServiceMap` builds Location layers lazily
@@ -36,6 +38,7 @@ export const layerWithStore = (store: Store) =>
Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
store.plugins.clear()
@@ -45,7 +48,7 @@ export const layerWithStore = (store: Store) =>
register: (plugin) =>
Effect.sync(() => {
store.plugins.set(plugin.id, plugin)
}),
}).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid),
all: () => [...store.plugins.values()],
})
}),
@@ -53,4 +56,4 @@ export const layerWithStore = (store: Store) =>
export const layer = layerWithStore(defaultStore)
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
export const node = makeGlobalNode({ service: Service, layer, deps: [EventV2.node] })
+1 -1
View File
@@ -276,7 +276,7 @@ const layer = Layer.effect(
}),
),
)
yield* events.subscribe(Event.Updated).pipe(
yield* events.subscribe([Event.Updated, SdkPlugins.Updated]).pipe(
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
),
+6 -3
View File
@@ -63,8 +63,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service
yield* ctx.tool
.register({
[name]: Tool.withPermission(
.transform((draft) =>
draft.add(
name,
Tool.withPermission(
Tool.make({
description:
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
@@ -189,7 +191,8 @@ export const Plugin = {
}),
"edit",
),
})
),
)
.pipe(Effect.orDie)
}),
}
+6 -3
View File
@@ -94,8 +94,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service
yield* ctx.tool
.register({
[name]: Tool.withPermission(
.transform((draft) =>
draft.add(
name,
Tool.withPermission(
Tool.make({
description:
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
@@ -209,7 +211,8 @@ export const Plugin = {
}),
"edit",
),
})
),
)
.pipe(Effect.orDie)
}),
}
+6 -3
View File
@@ -43,8 +43,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service
yield* ctx.tool
.register({
[name]: Tool.make({
.transform((draft) =>
draft.add(
name,
Tool.make({
description:
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Input,
@@ -104,7 +106,8 @@ export const Plugin = {
),
),
}),
})
),
)
.pipe(Effect.orDie)
}),
}
+6 -3
View File
@@ -57,8 +57,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service
yield* ctx.tool
.register({
[name]: Tool.make({
.transform((draft) =>
draft.add(
name,
Tool.make({
description:
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Input,
@@ -135,7 +137,8 @@ export const Plugin = {
),
),
}),
})
),
)
.pipe(Effect.orDie)
}),
}
+6 -3
View File
@@ -56,8 +56,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service
yield* ctx.tool
.register({
[name]: Tool.make({
.transform((draft) =>
draft.add(
name,
Tool.make({
description,
input: Input,
output: Output,
@@ -114,7 +116,8 @@ export const Plugin = {
}),
),
}),
})
),
)
.pipe(Effect.orDie)
}),
}
+9 -4
View File
@@ -42,8 +42,10 @@ export const Plugin = {
const location = yield* Location.Service
yield* ctx.tool
.register({
[name]: Tool.make({
.transform((draft) =>
draft.add(
name,
Tool.make({
description:
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
input: Input,
@@ -106,7 +108,9 @@ export const Plugin = {
start: type === "directory" ? resolved : dirname(resolved),
stop: root,
})
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter((file) => dirname(file) !== root)
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
(file) => dirname(file) !== root,
)
if (candidates.length === 0) return
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
}).pipe(
@@ -135,7 +139,8 @@ export const Plugin = {
)
},
}),
})
),
)
.pipe(Effect.orDie)
}),
}
+15 -10
View File
@@ -58,8 +58,7 @@ const modelOutput = (output: Output): string | undefined => {
const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: ""
if (output.status === "running")
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
if (output.status === "running") return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
}
@@ -140,8 +139,10 @@ export const Plugin = {
})
yield* ctx.tool
.register({
[name]: Tool.make({
.transform((draft) =>
draft.add(
name,
Tool.make({
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
input: Input,
output: Output,
@@ -247,9 +248,9 @@ export const Plugin = {
}
}
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
)
const result = yield* runtime.job
.block({ id: job.id, sessionID: context.sessionID })
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
if (result?.type === "backgrounded") {
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return {
@@ -260,16 +261,20 @@ export const Plugin = {
...(warnings.length ? { warnings } : {}),
}
}
if (result?.info.status === "error") return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "error")
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
return {
...(yield* settleShell()),
...(warnings.length ? { warnings } : {}),
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
}).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` })),
),
}),
})
),
)
.pipe(Effect.orDie)
}),
}
+6 -3
View File
@@ -59,8 +59,10 @@ export const Plugin = {
const skills = yield* SkillV2.Service
const permission = yield* PermissionV2.Service
yield* ctx.tool
.register({
[name]: Tool.make({
.transform((draft) =>
draft.add(
name,
Tool.make({
description,
input: Input,
output: Output,
@@ -95,7 +97,8 @@ export const Plugin = {
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
}),
}),
})
),
)
.pipe(Effect.orDie)
}),
}
+14 -6
View File
@@ -93,8 +93,10 @@ export const Plugin = {
})
yield* ctx.tool
.register({
[name]: Tool.make({
.transform((draft) =>
draft.add(
name,
Tool.make({
description,
input: Input,
output: Output,
@@ -104,7 +106,9 @@ export const Plugin = {
const parent = yield* runtime.session
.get(context.sessionID)
.pipe(
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
Effect.mapError(
() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }),
),
)
const agent = yield* agents.resolve(input.agent)
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
@@ -123,7 +127,9 @@ export const Plugin = {
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
})
.pipe(
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
Effect.mapError(
() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }),
),
)
const background = input.background === true
@@ -162,11 +168,13 @@ export const Plugin = {
}
if (result?.info.status === "error")
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
if (result?.info.status === "cancelled")
return yield* new ToolFailure({ message: "Subagent cancelled" })
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
}),
}),
})
),
)
.pipe(Effect.orDie)
}),
}
+6 -3
View File
@@ -27,8 +27,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service
yield* ctx.tool
.register({
[name]: Tool.make({
.transform((draft) =>
draft.add(
name,
Tool.make({
description:
"Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.",
input: Input,
@@ -48,7 +50,8 @@ export const Plugin = {
return { todos: input.todos }
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
}),
})
),
)
.pipe(Effect.orDie)
}),
}
+6 -3
View File
@@ -119,8 +119,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service
yield* ctx.tool
.register({
[name]: Tool.make({
.transform((draft) =>
draft.add(
name,
Tool.make({
description,
input: Input,
output: Output,
@@ -172,7 +174,8 @@ export const Plugin = {
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
}),
})
),
)
.pipe(Effect.orDie)
}),
}
+9 -4
View File
@@ -195,8 +195,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service
yield* ctx.tool
.register({
[name]: Tool.make({
.transform((draft) =>
draft.add(
name,
Tool.make({
description,
input: Input,
output: Output,
@@ -243,10 +245,13 @@ export const Plugin = {
provider,
text: text ?? NO_RESULTS,
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })))
}).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })),
)
},
}),
})
),
)
.pipe(Effect.orDie)
}),
}
+6 -3
View File
@@ -50,8 +50,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service
yield* ctx.tool
.register({
[name]: Tool.withPermission(
.transform((draft) =>
draft.add(
name,
Tool.withPermission(
Tool.make({
description:
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
@@ -87,7 +89,8 @@ export const Plugin = {
}),
"edit",
),
})
),
)
.pipe(Effect.orDie)
}),
}
+26
View File
@@ -61,6 +61,12 @@ const GlobalMessage = EventV2.ephemeral({
text: Schema.String,
},
})
const CountMessage = EventV2.ephemeral({
type: "test.count",
schema: {
count: Schema.Number,
},
})
const VersionedMessage = EventV2.durable({
type: "test.versioned",
@@ -90,6 +96,26 @@ const it = testEffect(
const itWithoutLocation = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node])))
describe("EventV2", () => {
it.effect("subscribes to multiple event definitions with a discriminated payload union", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
// @ts-expect-error multi-definition subscriptions require at least one definition
events.subscribe([])
const fiber = yield* events
.subscribe([Message, CountMessage])
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* events.publish(Message, { text: "hello" })
yield* events.publish(CountMessage, { count: 2 })
const received = Array.from(yield* Fiber.join(fiber)).map((event) =>
event.type === "test.message" ? event.data.text : event.data.count,
)
expect(received).toEqual(["hello", 2])
}),
)
it.effect("publishes events with the current location", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
+19 -1
View File
@@ -2,6 +2,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import type { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Tool } from "@opencode-ai/core/tool/tool"
import { Tools } from "@opencode-ai/core/tool/tools"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, type Scope } from "effect"
@@ -49,7 +50,24 @@ export const registerToolPlugin = <R>(plugin: {
const tools = yield* Tools.Service
const context: Pick<PluginContext, "tool"> = {
tool: {
register: tools.register,
transform: (callback) =>
Effect.gen(function* () {
const registrations: Array<{
readonly name: string
readonly tool: Tool.AnyTool
readonly options?: Tool.RegisterOptions
}> = []
callback({
add: (name, tool, options) => {
registrations.push({ name, tool, ...(options ? { options } : {}) })
},
})
yield* Effect.forEach(
registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true },
)
}),
execute: {
before: () => Effect.die("registerToolPlugin does not support tool hooks"),
after: () => Effect.die("registerToolPlugin does not support tool hooks"),
+19 -13
View File
@@ -165,14 +165,17 @@ describe("PluginV2", () => {
id: "tool-plugin",
effect: (ctx) =>
ctx.tool
.register({
plugin_tool: Tool.make({
.transform((draft) =>
draft.add(
"plugin_tool",
Tool.make({
description: "Plugin tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
})
),
)
.pipe(Effect.orDie),
})
@@ -202,13 +205,13 @@ describe("PluginV2", () => {
const plugin = define({
id: "grouped-tools",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool.register({ plain: tool("Plain") }).pipe(Effect.orDie)
yield* ctx.tool.register({ "look/up": tool("Lookup") }, { group: "context 7" }).pipe(Effect.orDie)
yield* ctx.tool
.register({ search: tool("Search") }, { group: "context 7", deferred: true })
.pipe(Effect.orDie)
}),
ctx.tool
.transform((draft) => {
draft.add("plain", tool("Plain"))
draft.add("look/up", tool("Lookup"), { group: "context 7" })
draft.add("search", tool("Search"), { group: "context 7", deferred: true })
})
.pipe(Effect.orDie),
})
yield* plugins.activate([{ plugin }])
@@ -236,14 +239,17 @@ describe("PluginV2", () => {
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool
.register({
echo: Tool.make({
.transform((draft) =>
draft.add(
"echo",
Tool.make({
description: "Echo",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
}),
})
),
)
.pipe(Effect.orDie)
yield* ctx.tool.execute
+1 -1
View File
@@ -71,7 +71,7 @@ export function host(overrides: Overrides = {}): PluginContext {
reload: () => Effect.die("unused skill.reload"),
},
tool: overrides.tool ?? {
register: () => Effect.die("unused tool.register"),
transform: () => Effect.die("unused tool.transform"),
execute: {
before: () => Effect.die("unused tool.execute.before"),
after: () => Effect.die("unused tool.execute.after"),
+1 -1
View File
@@ -10,5 +10,5 @@ export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
export type { SkillDraft, SkillHooks } from "./skill.js"
export * as Tool from "./tool.js"
export type { ToolDomain, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js"
export type { ToolDomain, ToolDraft, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js"
export type { SessionHooks } from "./runtime.js"
+5 -4
View File
@@ -249,10 +249,11 @@ export interface RegisterOptions {
readonly deferred?: boolean
}
export interface ToolDraft {
add(name: string, tool: AnyTool, options?: RegisterOptions): void
}
export interface ToolDomain {
readonly register: (
tools: Readonly<Record<string, AnyTool>>,
options?: RegisterOptions,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
readonly transform: (callback: (draft: ToolDraft) => void) => Effect.Effect<void, RegistrationError, Scope.Scope>
readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }>
}
+1 -1
View File
@@ -11,7 +11,7 @@ const opencode = yield * OpenCode.create()
const session = yield * opencode.sessions.get({ sessionID })
```
It also exports `Tool` for plugins that register tools with `ctx.tool.register(...)`. Embedded plugins run through the ordinary discovery flow and register tools into each Location's `ToolRegistry` through the normal `Tools.Service.register(...)` path. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
It also exports `Tool` for plugins that add tools with `ctx.tool.transform(...)`. Embedded plugins run through the ordinary discovery flow and register tools into each Location's `ToolRegistry` through the normal `Tools.Service.register(...)` path. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message.
+2 -1
View File
@@ -1,6 +1,7 @@
import { OpenCode } from "@opencode-ai/client/effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { Project } from "@opencode-ai/core/project"
@@ -13,7 +14,7 @@ export const create = Effect.fn("OpenCode.create")(function* () {
const memoMap = yield* Layer.makeMemoMap
const sdkPlugins = SdkPlugins.makeStore()
const context = yield* Layer.buildWithMemoMap(
AppNodeBuilder.build(LayerNode.group([PermissionSaved.node, Project.node, SdkPlugins.node]), [
AppNodeBuilder.build(LayerNode.group([EventV2.node, PermissionSaved.node, Project.node, SdkPlugins.node]), [
[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)],
]),
memoMap,
+121 -4
View File
@@ -1,6 +1,8 @@
import fs from "fs/promises"
import path from "path"
import { expect } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Deferred, Effect, Latch, Layer, Option, Schema, Stream } from "effect"
import { Deferred, Effect, Latch, Layer, Option, Ref, Schema, Stream } from "effect"
import { testEffect } from "../../core/test/lib/effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import type { OpenCodeEvent } from "../src"
@@ -26,6 +28,118 @@ const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create()
const location = (fixture: Fixture) =>
fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
it.live(
"reloads every booted Location after SDK plugin registration",
() =>
withEmbedded("opencode-embedded-plugin-reload-", (fixture) =>
Effect.gen(function* () {
const opencode = yield* fixture.sdk.OpenCode.create()
const booted = yield* Deferred.make<void>()
const activated = yield* Deferred.make<boolean>()
const bootCount = yield* Ref.make(0)
const activationCount = yield* Ref.make(0)
const secondDirectory = path.join(fixture.directory, "second")
yield* Effect.promise(() => fs.mkdir(secondDirectory))
const refs = [
location(fixture),
fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(secondDirectory) }),
]
const bootstrapID = `bootstrap-sdk-${crypto.randomUUID()}`
const id = `late-sdk-${crypto.randomUUID()}`
yield* opencode.plugin({
id: bootstrapID,
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool
.transform((draft) =>
draft.add(
"bootstrap_sdk_tool",
fixture.sdk.Tool.make({
description: "Marks the initial Location plugin generation",
input: Schema.Struct({}),
output: Schema.Void,
execute: () => Effect.void,
}),
),
)
.pipe(Effect.orDie)
if (yield* Ref.updateAndGet(bootCount, (count) => count + 1).pipe(Effect.map((count) => count === 2))) {
yield* Deferred.succeed(booted, undefined)
}
}),
})
yield* Effect.all(
refs.map((ref) => opencode.plugin.list({ location: ref })),
{ discard: true },
)
yield* Deferred.await(booted).pipe(Effect.timeout("4 seconds"))
yield* opencode.plugin({
id,
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool
.transform((draft) =>
draft.add(
"late_sdk_tool",
fixture.sdk.Tool.make({
description: "Tool registered after Location boot",
input: Schema.Struct({}),
output: Schema.Void,
execute: () => Effect.void,
}),
),
)
.pipe(Effect.orDie)
if (
yield* Ref.updateAndGet(activationCount, (count) => count + 1).pipe(Effect.map((count) => count === 2))
) {
yield* Deferred.succeed(activated, true)
}
}),
})
expect(yield* Deferred.await(activated).pipe(Effect.timeout("10 seconds"))).toBe(true)
}),
),
25_000,
)
it.live(
"keeps SDK plugin registration isolated between embedded hosts",
() =>
withEmbedded("opencode-embedded-plugin-isolation-", (fixture) =>
Effect.gen(function* () {
const first = yield* fixture.sdk.OpenCode.create()
const second = yield* fixture.sdk.OpenCode.create()
const firstReady = yield* Deferred.make<void>()
const secondReady = yield* Deferred.make<void>()
const activated = yield* Deferred.make<void>()
const ref = location(fixture)
const id = `isolated-sdk-${crypto.randomUUID()}`
yield* first.plugin({
id: `first-ready-${crypto.randomUUID()}`,
effect: () => Deferred.succeed(firstReady, undefined),
})
yield* second.plugin({
id: `second-ready-${crypto.randomUUID()}`,
effect: () => Deferred.succeed(secondReady, undefined),
})
yield* Effect.all([first.plugin.list({ location: ref }), second.plugin.list({ location: ref })], {
discard: true,
})
yield* Effect.all([Deferred.await(firstReady), Deferred.await(secondReady)], { discard: true })
yield* first.plugin({ id, effect: () => Deferred.succeed(activated, undefined) })
yield* Deferred.await(activated).pipe(Effect.timeout("5 seconds"))
expect((yield* second.plugin.list({ location: ref })).data.map((plugin) => String(plugin.id))).not.toContain(id)
}),
),
15_000,
)
it.live(
"embedded client uses the real router and handlers",
() =>
@@ -42,14 +156,17 @@ it.live(
id: `embedded-tools-${crypto.randomUUID()}`,
effect: (ctx) =>
ctx.tool
.register({
embedded_tool: fixture.sdk.Tool.make({
.transform((draft) =>
draft.add(
"embedded_tool",
fixture.sdk.Tool.make({
description: "Embedded test tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
})
),
)
.pipe(Effect.orDie),
})