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 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 { export interface Interface {
readonly publish: <D extends Definition>( readonly publish: <D extends Definition>(
definition: D, definition: D,
data: Data<D>, data: Data<D>,
options?: PublishOptions, options?: PublishOptions,
) => Effect.Effect<Payload<D>> ) => 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, * Volatile live channel: every event published from now on, nothing before,
* nothing across a disconnect. The only channel that carries non-durable * 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( local(Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))).pipe(
Stream.map((event) => event as Payload<D>), 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 streamLive = (): Stream.Stream<Payload> => local(Stream.fromPubSub(pubsub.live))
const readAfter = ( const readAfter = (
+21 -1
View File
@@ -18,6 +18,7 @@ import { ProviderV2 } from "../provider"
import { Reference } from "../reference" import { Reference } from "../reference"
import { AbsolutePath, type DeepMutable } from "../schema" import { AbsolutePath, type DeepMutable } from "../schema"
import { SkillV2 } from "../skill" import { SkillV2 } from "../skill"
import { Tool } from "../tool/tool"
import { Tools } from "../tool/tools" import { Tools } from "../tool/tools"
import { ToolHooks } from "../tool/hooks" import { ToolHooks } from "../tool/hooks"
import { WorkspaceV2 } from "../workspace" import { WorkspaceV2 } from "../workspace"
@@ -298,7 +299,26 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}), }),
}, },
tool: { 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: { execute: {
before: (callback) => before: (callback) =>
toolHooks.hook.before((event) => { 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 type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "../effect/app-node" import { makeGlobalNode } from "../effect/app-node"
import { EventV2 } from "../event"
export const Updated = EventV2.ephemeral({ type: "sdk.plugin.updated", schema: {} })
export interface Store { export interface Store {
readonly plugins: Map<string, Plugin> readonly plugins: Map<string, Plugin>
@@ -16,9 +19,8 @@ const defaultStore = makeStore()
* Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes, * Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes,
* so `PluginSupervisor` can add them on every Location boot through the ordinary * so `PluginSupervisor` can add them on every Location boot through the ordinary
* generation path that `PluginSupervisor` uses for plugins discovered from * generation path that `PluginSupervisor` uses for plugins discovered from
* config. A plugin registered after a Location has booted only * config. Registration publishes an unlocated update so every booted Location
* applies to Locations booted afterward, matching config-plugin timing; * reloads its plugin generation from the shared store.
* embedders register at startup before creating Sessions.
* *
* The store is shared explicitly between the SDK construction graph and the * The store is shared explicitly between the SDK construction graph and the
* embedded route graph because `LocationServiceMap` builds Location layers lazily * embedded route graph because `LocationServiceMap` builds Location layers lazily
@@ -36,6 +38,7 @@ export const layerWithStore = (store: Store) =>
Layer.effect( Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
Effect.sync(() => { Effect.sync(() => {
store.plugins.clear() store.plugins.clear()
@@ -45,7 +48,7 @@ export const layerWithStore = (store: Store) =>
register: (plugin) => register: (plugin) =>
Effect.sync(() => { Effect.sync(() => {
store.plugins.set(plugin.id, plugin) store.plugins.set(plugin.id, plugin)
}), }).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid),
all: () => [...store.plugins.values()], all: () => [...store.plugins.values()],
}) })
}), }),
@@ -53,4 +56,4 @@ export const layerWithStore = (store: Store) =>
export const layer = layerWithStore(defaultStore) 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(() => Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))), 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 const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.withPermission( draft.add(
name,
Tool.withPermission(
Tool.make({ Tool.make({
description: 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.", "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", "edit",
), ),
}) ),
)
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+6 -3
View File
@@ -94,8 +94,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.withPermission( draft.add(
name,
Tool.withPermission(
Tool.make({ Tool.make({
description: 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.", "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", "edit",
), ),
}) ),
)
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+6 -3
View File
@@ -43,8 +43,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
name,
Tool.make({
description: 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.", "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, input: Input,
@@ -104,7 +106,8 @@ export const Plugin = {
), ),
), ),
}), }),
}) ),
)
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+6 -3
View File
@@ -57,8 +57,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
name,
Tool.make({
description: 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.", "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, input: Input,
@@ -135,7 +137,8 @@ export const Plugin = {
), ),
), ),
}), }),
}) ),
)
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+6 -3
View File
@@ -56,8 +56,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
name,
Tool.make({
description, description,
input: Input, input: Input,
output: Output, output: Output,
@@ -114,7 +116,8 @@ export const Plugin = {
}), }),
), ),
}), }),
}) ),
)
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+9 -4
View File
@@ -42,8 +42,10 @@ export const Plugin = {
const location = yield* Location.Service const location = yield* Location.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
name,
Tool.make({
description: 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.", "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, input: Input,
@@ -106,7 +108,9 @@ export const Plugin = {
start: type === "directory" ? resolved : dirname(resolved), start: type === "directory" ? resolved : dirname(resolved),
stop: root, 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 if (candidates.length === 0) return
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates }) yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
}).pipe( }).pipe(
@@ -135,7 +139,8 @@ export const Plugin = {
) )
}, },
}), }),
}) ),
)
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+15 -10
View File
@@ -58,8 +58,7 @@ const modelOutput = (output: Output): string | undefined => {
const warnings = output.warnings?.length const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: "" : ""
if (output.status === "running") if (output.status === "running") return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.` 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}.` return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
} }
@@ -140,8 +139,10 @@ export const Plugin = {
}) })
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ 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.`, 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, input: Input,
output: Output, output: Output,
@@ -247,9 +248,9 @@ export const Plugin = {
} }
} }
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe( const result = yield* runtime.job
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)), .block({ id: job.id, sessionID: context.sessionID })
) .pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
if (result?.type === "backgrounded") { if (result?.type === "backgrounded") {
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command) yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return { return {
@@ -260,16 +261,20 @@ export const Plugin = {
...(warnings.length ? { warnings } : {}), ...(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")) if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
return { return {
...(yield* settleShell()), ...(yield* settleShell()),
...(warnings.length ? { warnings } : {}), ...(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) .pipe(Effect.orDie)
}), }),
} }
+6 -3
View File
@@ -59,8 +59,10 @@ export const Plugin = {
const skills = yield* SkillV2.Service const skills = yield* SkillV2.Service
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
name,
Tool.make({
description, description,
input: Input, input: Input,
output: Output, output: Output,
@@ -95,7 +97,8 @@ export const Plugin = {
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error))) }).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
}), }),
}), }),
}) ),
)
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+14 -6
View File
@@ -93,8 +93,10 @@ export const Plugin = {
}) })
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
name,
Tool.make({
description, description,
input: Input, input: Input,
output: Output, output: Output,
@@ -104,7 +106,9 @@ export const Plugin = {
const parent = yield* runtime.session const parent = yield* runtime.session
.get(context.sessionID) .get(context.sessionID)
.pipe( .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) const agent = yield* agents.resolve(input.agent)
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${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. // session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
}) })
.pipe( .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 const background = input.background === true
@@ -162,11 +168,13 @@ export const Plugin = {
} }
if (result?.info.status === "error") if (result?.info.status === "error")
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" }) 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 } return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
}), }),
}), }),
}) ),
)
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+6 -3
View File
@@ -27,8 +27,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
name,
Tool.make({
description: 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.", "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, input: Input,
@@ -48,7 +50,8 @@ export const Plugin = {
return { todos: input.todos } return { todos: input.todos }
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))), }).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
}), }),
}) ),
)
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+6 -3
View File
@@ -119,8 +119,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
name,
Tool.make({
description, description,
input: Input, input: Input,
output: Output, output: Output,
@@ -172,7 +174,8 @@ export const Plugin = {
} }
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))), }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
}), }),
}) ),
)
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+9 -4
View File
@@ -195,8 +195,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
name,
Tool.make({
description, description,
input: Input, input: Input,
output: Output, output: Output,
@@ -243,10 +245,13 @@ export const Plugin = {
provider, provider,
text: text ?? NO_RESULTS, 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) .pipe(Effect.orDie)
}), }),
} }
+6 -3
View File
@@ -50,8 +50,10 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.withPermission( draft.add(
name,
Tool.withPermission(
Tool.make({ Tool.make({
description: 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.", "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", "edit",
), ),
}) ),
)
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+26
View File
@@ -61,6 +61,12 @@ const GlobalMessage = EventV2.ephemeral({
text: Schema.String, text: Schema.String,
}, },
}) })
const CountMessage = EventV2.ephemeral({
type: "test.count",
schema: {
count: Schema.Number,
},
})
const VersionedMessage = EventV2.durable({ const VersionedMessage = EventV2.durable({
type: "test.versioned", type: "test.versioned",
@@ -90,6 +96,26 @@ const it = testEffect(
const itWithoutLocation = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node]))) const itWithoutLocation = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node])))
describe("EventV2", () => { 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", () => it.effect("publishes events with the current location", () =>
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service 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 type { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" 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 { Tools } from "@opencode-ai/core/tool/tools"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, type Scope } from "effect" import { Effect, type Scope } from "effect"
@@ -49,7 +50,24 @@ export const registerToolPlugin = <R>(plugin: {
const tools = yield* Tools.Service const tools = yield* Tools.Service
const context: Pick<PluginContext, "tool"> = { const context: Pick<PluginContext, "tool"> = {
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: { execute: {
before: () => Effect.die("registerToolPlugin does not support tool hooks"), before: () => Effect.die("registerToolPlugin does not support tool hooks"),
after: () => 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", id: "tool-plugin",
effect: (ctx) => effect: (ctx) =>
ctx.tool ctx.tool
.register({ .transform((draft) =>
plugin_tool: Tool.make({ draft.add(
"plugin_tool",
Tool.make({
description: "Plugin tool", description: "Plugin tool",
input: Schema.Struct({}), input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }), output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }), execute: () => Effect.succeed({ ok: true }),
}), }),
}) ),
)
.pipe(Effect.orDie), .pipe(Effect.orDie),
}) })
@@ -202,13 +205,13 @@ describe("PluginV2", () => {
const plugin = define({ const plugin = define({
id: "grouped-tools", id: "grouped-tools",
effect: (ctx) => effect: (ctx) =>
Effect.gen(function* () { ctx.tool
yield* ctx.tool.register({ plain: tool("Plain") }).pipe(Effect.orDie) .transform((draft) => {
yield* ctx.tool.register({ "look/up": tool("Lookup") }, { group: "context 7" }).pipe(Effect.orDie) draft.add("plain", tool("Plain"))
yield* ctx.tool draft.add("look/up", tool("Lookup"), { group: "context 7" })
.register({ search: tool("Search") }, { group: "context 7", deferred: true }) draft.add("search", tool("Search"), { group: "context 7", deferred: true })
.pipe(Effect.orDie) })
}), .pipe(Effect.orDie),
}) })
yield* plugins.activate([{ plugin }]) yield* plugins.activate([{ plugin }])
@@ -236,14 +239,17 @@ describe("PluginV2", () => {
effect: (ctx) => effect: (ctx) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
echo: Tool.make({ draft.add(
"echo",
Tool.make({
description: "Echo", description: "Echo",
input: Schema.Struct({ text: Schema.String }), input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })), execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
}), }),
}) ),
)
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* ctx.tool.execute yield* ctx.tool.execute
+1 -1
View File
@@ -71,7 +71,7 @@ export function host(overrides: Overrides = {}): PluginContext {
reload: () => Effect.die("unused skill.reload"), reload: () => Effect.die("unused skill.reload"),
}, },
tool: overrides.tool ?? { tool: overrides.tool ?? {
register: () => Effect.die("unused tool.register"), transform: () => Effect.die("unused tool.transform"),
execute: { execute: {
before: () => Effect.die("unused tool.execute.before"), before: () => Effect.die("unused tool.execute.before"),
after: () => Effect.die("unused tool.execute.after"), 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 { ReferenceDraft, ReferenceHooks } from "./reference.js"
export type { SkillDraft, SkillHooks } from "./skill.js" export type { SkillDraft, SkillHooks } from "./skill.js"
export * as Tool from "./tool.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" export type { SessionHooks } from "./runtime.js"
+5 -4
View File
@@ -249,10 +249,11 @@ export interface RegisterOptions {
readonly deferred?: boolean readonly deferred?: boolean
} }
export interface ToolDraft {
add(name: string, tool: AnyTool, options?: RegisterOptions): void
}
export interface ToolDomain { export interface ToolDomain {
readonly register: ( readonly transform: (callback: (draft: ToolDraft) => void) => Effect.Effect<void, RegistrationError, Scope.Scope>
tools: Readonly<Record<string, AnyTool>>,
options?: RegisterOptions,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }> 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 }) 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. `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 { OpenCode } from "@opencode-ai/client/effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" 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 { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { Project } from "@opencode-ai/core/project" 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 memoMap = yield* Layer.makeMemoMap
const sdkPlugins = SdkPlugins.makeStore() const sdkPlugins = SdkPlugins.makeStore()
const context = yield* Layer.buildWithMemoMap( 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)], [SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)],
]), ]),
memoMap, memoMap,
+121 -4
View File
@@ -1,6 +1,8 @@
import fs from "fs/promises"
import path from "path"
import { expect } from "bun:test" import { expect } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag" 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 { testEffect } from "../../core/test/lib/effect"
import { tmpdir } from "../../core/test/fixture/tmpdir" import { tmpdir } from "../../core/test/fixture/tmpdir"
import type { OpenCodeEvent } from "../src" import type { OpenCodeEvent } from "../src"
@@ -26,6 +28,118 @@ const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create()
const location = (fixture: Fixture) => const location = (fixture: Fixture) =>
fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) }) 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( it.live(
"embedded client uses the real router and handlers", "embedded client uses the real router and handlers",
() => () =>
@@ -42,14 +156,17 @@ it.live(
id: `embedded-tools-${crypto.randomUUID()}`, id: `embedded-tools-${crypto.randomUUID()}`,
effect: (ctx) => effect: (ctx) =>
ctx.tool ctx.tool
.register({ .transform((draft) =>
embedded_tool: fixture.sdk.Tool.make({ draft.add(
"embedded_tool",
fixture.sdk.Tool.make({
description: "Embedded test tool", description: "Embedded test tool",
input: Schema.Struct({}), input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }), output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }), execute: () => Effect.succeed({ ok: true }),
}), }),
}) ),
)
.pipe(Effect.orDie), .pipe(Effect.orDie),
}) })