feat(plugin): add session request hook (#35794)

This commit is contained in:
Dax 2026-07-07 19:56:47 -04:00 committed by GitHub
commit 4f976bcf1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
124 changed files with 7743 additions and 5620 deletions

View file

@ -1,6 +1,6 @@
import { define } from "@opencode-ai/plugin/v2/promise"
import { Plugin } from "@opencode-ai/plugin/v2"
export default define({
export default Plugin.define({
id: "directory-plugin",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {

View file

@ -1,6 +1,6 @@
import { define } from "@opencode-ai/plugin/v2/promise"
import { Plugin } from "@opencode-ai/plugin/v2"
export default define({
export default Plugin.define({
id: "folder-plugin",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {

View file

@ -2,7 +2,7 @@ import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect } from "bun:test"
import { define } from "@opencode-ai/plugin/v2/effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin"
import { AgentV2 } from "@opencode-ai/core/agent"
@ -178,7 +178,7 @@ describe("PluginSupervisor config", () => {
it.live("loads user plugins before internal post plugins", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void }))
yield* withLocation(
{
plugins: [
@ -275,7 +275,7 @@ function mutablePlugin(description: string) {
return `
import { define } from ${JSON.stringify(plugin)}
export default define({
export default EffectPlugin.define({
id: "mutable-plugin",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {

View file

@ -4,7 +4,7 @@ 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 type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, type Scope } from "effect"
export const toolIdentity = {
@ -66,12 +66,10 @@ export const registerToolPlugin = <R>(plugin: {
registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true },
)
).pipe(Effect.orDie)
return { dispose: Effect.void }
}),
execute: {
before: () => Effect.die("registerToolPlugin does not support tool hooks"),
after: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
}
yield* plugin.effect(context as PluginContext)

View file

@ -4,7 +4,7 @@ import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -44,7 +44,7 @@ describe("LocationServiceMap", () => {
const sdk = yield* SdkPlugins.Service
const locations = yield* LocationServiceMap.Service
const id = AgentV2.ID.make("persistent-sdk-agent")
const plugin = define({
const plugin = EffectPlugin.define({
id: "persistent-sdk-plugin",
effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})),
})
@ -432,7 +432,7 @@ describe("LocationServiceMap", () => {
Effect.flatMap((dir) =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const reviewer = define({
const reviewer = EffectPlugin.define({
id: "reviewer",
effect: (ctx) =>
ctx.agent

View file

@ -0,0 +1,47 @@
import { describe, expect, it } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/llm"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Session } from "@opencode-ai/schema/session"
import { Effect, Layer } from "effect"
import { PluginHooks } from "../src/plugin/hooks"
describe("PluginHooks", () => {
it("registers scoped domain hooks and triggers them sequentially", async () => {
const seen: string[] = []
const program = Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "request", (event) =>
Effect.sync(() => {
seen.push("first")
event.system.push(SystemPart.make("second"))
}),
)
yield* hooks.register("session", "request", (event) =>
Effect.sync(() => {
seen.push(event.system[1]?.text ?? "missing")
event.messages = [Message.user("changed")]
}),
)
const event = {
sessionID: Session.ID.make("ses_hooks"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
system: [SystemPart.make("first")],
messages: [Message.user("original")],
tools: {},
}
expect(yield* hooks.trigger("session", "request", event)).toBe(event)
expect(seen).toEqual(["first", "second"])
expect(event.messages).toEqual([Message.user("changed")])
})
await Effect.runPromise(
Effect.scoped(program).pipe(
Effect.provide(PluginHooks.node.implementation as Layer.Layer<PluginHooks.Service>),
),
)
})
})

View file

@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin"
import { AgentV2 } from "@opencode-ai/core/agent"
@ -49,7 +49,7 @@ describe("PluginV2", () => {
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
const managed = () =>
define({
EffectPlugin.define({
id: "managed",
effect: (ctx) =>
ctx.agent
@ -97,25 +97,40 @@ describe("PluginV2", () => {
}),
)
it.effect("retries the same generation after materialization fails", () =>
it.effect("skips failed plugins and loads the rest", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
let fail = true
const plugin = define({
id: "retry",
const good = EffectPlugin.define({
id: "good",
effect: (ctx) =>
ctx.agent
.transform(() => {
if (fail) throw new Error("materialization failed")
})
.transform((agents) =>
agents.update("configured", (agent) => {
agent.description = "loaded"
}),
)
.pipe(Effect.asVoid),
})
const bad = EffectPlugin.define({
id: "bad",
effect: () => {
if (fail) return Effect.die(new Error("materialization failed"))
return Effect.void
},
})
yield* plugins.activate([{ plugin: good }, { plugin: bad }])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("loaded")
expect(Exit.isFailure(yield* plugins.activate([{ plugin }]).pipe(Effect.exit))).toBe(true)
fail = false
yield* plugins.activate([{ plugin }])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("retry") }])
yield* plugins.activate([{ plugin: good }, { plugin: bad }])
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("good") },
{ id: Plugin.ID.make("bad") },
])
}),
)
@ -142,7 +157,7 @@ describe("PluginV2", () => {
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
let visible = true
const plugin = define({
const plugin = EffectPlugin.define({
id: "isolated",
effect: () =>
Effect.serviceOption(Secret).pipe(
@ -161,7 +176,7 @@ describe("PluginV2", () => {
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
const plugin = define({
const plugin = EffectPlugin.define({
id: "tool-plugin",
effect: (ctx) =>
ctx.tool
@ -202,7 +217,7 @@ describe("PluginV2", () => {
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
})
const plugin = define({
const plugin = EffectPlugin.define({
id: "grouped-tools",
effect: (ctx) =>
ctx.tool
@ -234,7 +249,7 @@ describe("PluginV2", () => {
after?: { input: unknown; result: unknown; output: unknown }
} = {}
const plugin = define({
const plugin = EffectPlugin.define({
id: "tool-hooks",
effect: (ctx) =>
Effect.gen(function* () {
@ -252,19 +267,23 @@ describe("PluginV2", () => {
)
.pipe(Effect.orDie)
yield* ctx.tool.execute
.before((event) => {
seen.before = event.input
event.input = { text: "before-mutated" }
})
yield* ctx.tool
.hook("execute.before", (event) =>
Effect.sync(() => {
seen.before = event.input
event.input = { text: "before-mutated" }
}),
)
.pipe(Effect.asVoid)
yield* ctx.tool.execute
.after((event) => {
seen.after = { input: event.input, result: event.result, output: event.output }
event.result = { type: "text", value: "after-mutated" }
event.output = { structured: { rewritten: true }, content: [] }
})
yield* ctx.tool
.hook("execute.after", (event) =>
Effect.sync(() => {
seen.after = { input: event.input, result: event.result, output: event.output }
event.result = { type: "text", value: "after-mutated" }
event.output = { structured: { rewritten: true }, content: [] }
}),
)
.pipe(Effect.asVoid)
}),
})

View file

@ -13,6 +13,7 @@ import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/core/npm"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Reference } from "@opencode-ai/core/reference"
import { SkillV2 } from "@opencode-ai/core/skill"

View file

@ -1,7 +1,7 @@
import { define } from "@opencode-ai/plugin/v2/effect"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect"
export default define({
export default Plugin.define({
id: "config-effect-plugin",
effect: (ctx) =>
ctx.agent

View file

@ -1,6 +1,6 @@
import { define } from "@opencode-ai/plugin/v2/promise"
import { Plugin } from "@opencode-ai/plugin/v2"
export default define({
export default Plugin.define({
id: "config-promise-plugin",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {

View file

@ -1,7 +1,7 @@
import { define } from "@opencode-ai/plugin/v2/effect"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect"
export default define({
export default Plugin.define({
id: "failing-plugin",
effect: () => Effect.die("plugin failed"),
})

View file

@ -1,8 +1,8 @@
import { define } from "@opencode-ai/plugin/v2/effect"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Effect } from "effect"
export default define({
export default Plugin.define({
id: "variant-source",
effect: (ctx) =>
ctx.catalog

View file

@ -1,4 +1,4 @@
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
@ -19,8 +19,7 @@ export function host(overrides: Overrides = {}): PluginContext {
reload: () => Effect.die("unused agent.reload"),
},
aisdk: overrides.aisdk ?? {
sdk: () => Effect.die("unused aisdk.sdk"),
language: () => Effect.die("unused aisdk.language"),
hook: () => Effect.die("unused aisdk.hook"),
},
catalog: overrides.catalog ?? {
provider: {
@ -45,11 +44,15 @@ export function host(overrides: Overrides = {}): PluginContext {
integration: overrides.integration ?? {
list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"),
connectKey: () => Effect.die("unused integration.connectKey"),
connectOauth: () => Effect.die("unused integration.connectOauth"),
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
attemptComplete: () => Effect.die("unused integration.attemptComplete"),
attemptCancel: () => Effect.die("unused integration.attemptCancel"),
connect: {
key: () => Effect.die("unused integration.connect.key"),
oauth: () => Effect.die("unused integration.connect.oauth"),
},
attempt: {
status: () => Effect.die("unused integration.attempt.status"),
complete: () => Effect.die("unused integration.attempt.complete"),
cancel: () => Effect.die("unused integration.attempt.cancel"),
},
transform: () => Effect.die("unused integration.transform"),
reload: () => Effect.die("unused integration.reload"),
connection: {
@ -72,10 +75,7 @@ export function host(overrides: Overrides = {}): PluginContext {
},
tool: overrides.tool ?? {
transform: () => Effect.die("unused tool.transform"),
execute: {
before: () => Effect.die("unused tool.execute.before"),
after: () => Effect.die("unused tool.execute.after"),
},
hook: () => Effect.die("unused tool.hook"),
},
session: overrides.session ?? {
create: () => Effect.die("unused session.create"),
@ -83,6 +83,7 @@ export function host(overrides: Overrides = {}): PluginContext {
prompt: () => Effect.die("unused session.prompt"),
command: () => Effect.die("unused session.command"),
interrupt: () => Effect.die("unused session.interrupt"),
hook: () => Effect.die("unused session.hook"),
},
}
}
@ -188,11 +189,15 @@ export function integrationHost(integration: Integration.Interface): PluginConte
return {
list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"),
connectKey: () => Effect.die("unused integration.connectKey"),
connectOauth: () => Effect.die("unused integration.connectOauth"),
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
attemptComplete: () => Effect.die("unused integration.attemptComplete"),
attemptCancel: () => Effect.die("unused integration.attemptCancel"),
connect: {
key: () => Effect.die("unused integration.connect.key"),
oauth: () => Effect.die("unused integration.connect.oauth"),
},
attempt: {
status: () => Effect.die("unused integration.attempt.status"),
complete: () => Effect.die("unused integration.attempt.complete"),
cancel: () => Effect.die("unused integration.attempt.cancel"),
},
reload: integration.reload,
connection: {
active: (id) => integration.connection.active(Integration.ID.make(id)),

View file

@ -4,7 +4,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { define } from "@opencode-ai/plugin/v2/promise"
import { Plugin } from "@opencode-ai/plugin/v2"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@ -16,7 +16,7 @@ describe("fromPromise", () => {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
const seen: string[] = []
const promisePlugin = define({
const promisePlugin = Plugin.define({
id: "promise-client-reads",
setup: async (ctx) => {
const results = await Promise.all([
@ -46,7 +46,7 @@ describe("fromPromise", () => {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
const promisePlugin = define({
const promisePlugin = Plugin.define({
id: "promise-example",
setup: async (ctx) => {
expect(ctx.options.mode).toBe("strict")
@ -75,7 +75,7 @@ describe("fromPromise", () => {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
const promisePlugin = define({
const promisePlugin = Plugin.define({
id: "promise-dispose",
setup: async (ctx) => {
const registration = await ctx.agent.transform((draft) => {

View file

@ -82,7 +82,7 @@ describe("ToolRegistry", () => {
}),
)
it.effect("selects one edit tool family for each model", () =>
it.effect("materializes all permission-eligible edit tools before request policy", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
@ -96,10 +96,13 @@ describe("ToolRegistry", () => {
.materialize({ model })
.pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name)))
expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "patch"])
expect(yield* names({ id: "gpt-4o", provider: "opencode" })).toEqual(["read", "patch"])
expect(yield* names({ id: "computer-use-preview", provider: "openai" })).toEqual(["read", "patch"])
expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual(["read", "edit", "write"])
expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "edit", "write", "patch"])
expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual([
"read",
"edit",
"write",
"patch",
])
}),
)