fix(plugin): reload late SDK plugins (#35576)
This commit is contained in:
@@ -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 = (
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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] })
|
||||||
|
|||||||
@@ -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 }))),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 }>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user