refactor(plugin): scope context hook to session (#37175)

Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
opencode-agent[bot] 2026-07-15 16:32:00 -04:00 committed by GitHub
commit f92d84746b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 85 additions and 97 deletions

View file

@ -1,15 +1,15 @@
export * as PluginHooks from "./hooks" export * as PluginHooks from "./hooks"
import type { AIHooks } from "@opencode-ai/plugin/v2/effect/ai"
import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk" import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool" import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool"
import { Context, Effect, Layer, Scope } from "effect" import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "../effect/app-node" import { makeLocationNode } from "../effect/app-node"
import { State } from "../state" import { State } from "../state"
export interface Domains { export interface Domains {
readonly ai: AIHooks
readonly aisdk: AISDKHooks readonly aisdk: AISDKHooks
readonly session: SessionHooks
readonly tool: ToolHooks readonly tool: ToolHooks
} }

View file

@ -80,9 +80,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}) })
}), }),
}, },
ai: {
hook: (name, callback) => hooks.register("ai", name, callback),
},
aisdk: { aisdk: {
hook: (name, callback) => { hook: (name, callback) => {
if (name === "sdk") { if (name === "sdk") {
@ -370,6 +367,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}, },
}, },
session: { session: {
hook: (name, callback) => hooks.register("session", name, callback),
create: (input) => create: (input) =>
runtime.session.create({ runtime.session.create({
id: input?.id, id: input?.id,

View file

@ -63,10 +63,6 @@ export function fromPromise(plugin: Plugin) {
transform: transform(host.agent), transform: transform(host.agent),
reload: () => run(host.agent.reload()), reload: () => run(host.agent.reload()),
}, },
ai: {
hook: (name, callback) =>
register(host.ai.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
aisdk: { aisdk: {
hook: (name, callback) => hook: (name, callback) =>
register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
@ -166,6 +162,8 @@ export function fromPromise(plugin: Plugin) {
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
}, },
session: { session: {
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
create: (input) => create: (input) =>
run( run(
host.session.create( host.session.create(

View file

@ -10,7 +10,7 @@ import {
isContextOverflowFailure, isContextOverflowFailure,
type ProviderErrorEvent, type ProviderErrorEvent,
} from "@opencode-ai/ai" } from "@opencode-ai/ai"
import type { AIHooks } from "@opencode-ai/plugin/v2/effect/ai" import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { SessionError } from "@opencode-ai/schema/session-error" import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
@ -216,7 +216,7 @@ const layer = Layer.effect(
toolChoice: isLastStep ? "none" : undefined, toolChoice: isLastStep ? "none" : undefined,
}) })
const availableTools = new Map(request.tools.map((tool) => [tool.name, tool])) const availableTools = new Map(request.tools.map((tool) => [tool.name, tool]))
const requestEvent: AIHooks["request"] = { const contextEvent: SessionHooks["context"] = {
sessionID: session.id, sessionID: session.id,
agent: agent.id, agent: agent.id,
model: resolved.ref, model: resolved.ref,
@ -228,11 +228,11 @@ const layer = Layer.effect(
} }
// Plugins may reshape the draft but cannot advertise tools excluded by // Plugins may reshape the draft but cannot advertise tools excluded by
// permissions, registration state, or the selected agent's step limit. // permissions, registration state, or the selected agent's step limit.
yield* hooks.trigger("ai", "request", requestEvent) yield* hooks.trigger("session", "context", contextEvent)
const hookedRequest = LLM.updateRequest(request, { const hookedRequest = LLM.updateRequest(request, {
system: requestEvent.system, system: contextEvent.system,
messages: requestEvent.messages, messages: contextEvent.messages,
tools: Object.entries(requestEvent.tools).flatMap(([name, tool]) => { tools: Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
const registered = availableTools.get(name) const registered = availableTools.get(name)
if (!registered) return [] if (!registered) return []
return [{ ...registered, description: tool.description, inputSchema: tool.input }] return [{ ...registered, description: tool.description, inputSchema: tool.input }]

View file

@ -196,7 +196,7 @@ export const Plugin = {
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* ctx.ai.hook("request", (event) => yield* ctx.session.hook("context", (event) =>
Effect.sync(() => { Effect.sync(() => {
const usePatch = const usePatch =
event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt") event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt")

View file

@ -216,7 +216,7 @@ export const Plugin = {
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* ctx.ai.hook("request", (event) => yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () { Effect.gen(function* () {
const tool = event.tools[name] const tool = event.tools[name]
if (!tool) return if (!tool) return

View file

@ -44,7 +44,7 @@ export const registerToolPlugin = <R>(plugin: {
Effect.gen(function* () { Effect.gen(function* () {
const tools = yield* Tools.Service const tools = yield* Tools.Service
const context = host({ const context = host({
ai: { session: {
hook: () => Effect.succeed({ dispose: Effect.void }), hook: () => Effect.succeed({ dispose: Effect.void }),
}, },
tool: { tool: {

View file

@ -12,17 +12,17 @@ const layer = PluginHooks.node.implementation as Layer.Layer<PluginHooks.Service
const it = testEffect(layer) const it = testEffect(layer)
describe("PluginHooks", () => { describe("PluginHooks", () => {
it.effect("registers scoped AI hooks and triggers them sequentially", () => it.effect("registers scoped session hooks and triggers them sequentially", () =>
Effect.gen(function* () { Effect.gen(function* () {
const hooks = yield* PluginHooks.Service const hooks = yield* PluginHooks.Service
const seen: string[] = [] const seen: string[] = []
yield* hooks.register("ai", "request", (event) => yield* hooks.register("session", "context", (event) =>
Effect.sync(() => { Effect.sync(() => {
seen.push("first") seen.push("first")
event.system.push(SystemPart.make("second")) event.system.push(SystemPart.make("second"))
}), }),
) )
yield* hooks.register("ai", "request", (event) => yield* hooks.register("session", "context", (event) =>
Effect.sync(() => { Effect.sync(() => {
seen.push(event.system[1]?.text ?? "missing") seen.push(event.system[1]?.text ?? "missing")
event.messages = [Message.user("changed")] event.messages = [Message.user("changed")]
@ -37,7 +37,7 @@ describe("PluginHooks", () => {
tools: {}, tools: {},
} }
expect(yield* hooks.trigger("ai", "request", event)).toBe(event) expect(yield* hooks.trigger("session", "context", event)).toBe(event)
expect(seen).toEqual(["first", "second"]) expect(seen).toEqual(["first", "second"])
expect(event.messages).toEqual([Message.user("changed")]) expect(event.messages).toEqual([Message.user("changed")])
}), }),

View file

@ -8,7 +8,9 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types" import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect" import { Effect, Stream } from "effect"
type Overrides = Partial<Omit<PluginContext, "options">> type Overrides = Partial<Omit<PluginContext, "options" | "session">> & {
readonly session?: Partial<PluginContext["session"]>
}
export function host(overrides: Overrides = {}): PluginContext { export function host(overrides: Overrides = {}): PluginContext {
return { return {
@ -18,9 +20,6 @@ export function host(overrides: Overrides = {}): PluginContext {
transform: () => Effect.die("unused agent.transform"), transform: () => Effect.die("unused agent.transform"),
reload: () => Effect.die("unused agent.reload"), reload: () => Effect.die("unused agent.reload"),
}, },
ai: overrides.ai ?? {
hook: () => Effect.die("unused ai.hook"),
},
aisdk: overrides.aisdk ?? { aisdk: overrides.aisdk ?? {
hook: () => Effect.die("unused aisdk.hook"), hook: () => Effect.die("unused aisdk.hook"),
}, },
@ -80,12 +79,13 @@ export function host(overrides: Overrides = {}): PluginContext {
transform: () => Effect.die("unused tool.transform"), transform: () => Effect.die("unused tool.transform"),
hook: () => Effect.die("unused tool.hook"), hook: () => Effect.die("unused tool.hook"),
}, },
session: overrides.session ?? { session: {
create: () => Effect.die("unused session.create"), hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
get: () => Effect.die("unused session.get"), create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
prompt: () => Effect.die("unused session.prompt"), get: overrides.session?.get ?? (() => Effect.die("unused session.get")),
command: () => Effect.die("unused session.command"), prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")),
interrupt: () => Effect.die("unused session.interrupt"), command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
}, },
} }
} }

View file

@ -10,7 +10,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
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 { Plugin } from "@opencode-ai/plugin/v2" import { Plugin } from "@opencode-ai/plugin/v2"
import type { AIHooks } from "@opencode-ai/plugin/v2/effect/ai" import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { Model } from "@opencode-ai/schema/model" import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider" import { Provider } from "@opencode-ai/schema/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@ -77,24 +77,24 @@ describe("fromPromise", () => {
}), }),
) )
it.effect("forwards AI request hooks", () => it.effect("forwards session context hooks", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const hooks = yield* PluginHooks.Service const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin) const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise( yield* PluginPromise.fromPromise(
Plugin.define({ Plugin.define({
id: "promise-ai-request", id: "promise-session-context",
setup: async (ctx) => { setup: async (ctx) => {
await ctx.ai.hook("request", (event) => { await ctx.session.hook("context", (event) => {
event.system.push(SystemPart.make("Promise hook")) event.system.push(SystemPart.make("Promise hook"))
delete event.tools.echo delete event.tools.echo
}) })
}, },
}), }),
).effect(host) ).effect(host)
const event: AIHooks["request"] = { const event: SessionHooks["context"] = {
sessionID: SessionV2.ID.make("ses_promise_ai_request"), sessionID: SessionV2.ID.make("ses_promise_session_context"),
agent: AgentV2.ID.make("build"), agent: AgentV2.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
system: [SystemPart.make("Initial")], system: [SystemPart.make("Initial")],
@ -102,7 +102,7 @@ describe("fromPromise", () => {
tools: { echo: { description: "Echo", input: { type: "object" } } }, tools: { echo: { description: "Echo", input: { type: "object" } } },
} }
yield* hooks.trigger("ai", "request", event) yield* hooks.trigger("session", "context", event)
expect(event.system.map((part) => part.text)).toEqual(["Initial", "Promise hook"]) expect(event.system.map((part) => part.text)).toEqual(["Initial", "Promise hook"])
expect(event.tools).toEqual({}) expect(event.tools).toEqual({})

View file

@ -777,11 +777,11 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
}) })
describe("SessionRunnerLLM", () => { describe("SessionRunnerLLM", () => {
it.effect("applies AI request hooks without exposing unavailable tools", () => it.effect("applies session context hooks without exposing unavailable tools", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setup const session = yield* setup
const hooks = yield* PluginHooks.Service const hooks = yield* PluginHooks.Service
yield* hooks.register("ai", "request", (event) => yield* hooks.register("session", "context", (event) =>
Effect.sync(() => { Effect.sync(() => {
event.system = [SystemPart.make("Hooked system")] event.system = [SystemPart.make("Hooked system")]
event.messages = [Message.user("Hooked message")] event.messages = [Message.user("Hooked message")]

View file

@ -80,11 +80,11 @@ yield *
Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks. Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.
AI request context is mutable immediately before provider dispatch: Session context is mutable immediately before provider dispatch:
```ts ```ts
yield * yield *
ctx.ai.hook("request", (event) => ctx.session.hook("context", (event) =>
Effect.sync(() => { Effect.sync(() => {
event.tools.read.description = "Read a file using narrow line ranges." event.tools.read.description = "Read a file using narrow line ranges."
delete event.tools.write delete event.tools.write

View file

@ -1,23 +0,0 @@
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export interface AIRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
system: Array<SystemPart>
messages: Array<Message>
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface AIHooks {
readonly request: AIRequest
}
export interface AIDomain {
readonly hook: Hooks<AIHooks>
}

View file

@ -2,7 +2,6 @@ import type { PluginApi } from "@opencode-ai/client/effect/api"
import type { Effect, Scope } from "effect" import type { Effect, Scope } from "effect"
import type { PluginOptions } from "../options.js" import type { PluginOptions } from "../options.js"
import type { AgentDomain } from "./agent.js" import type { AgentDomain } from "./agent.js"
import type { AIDomain } from "./ai.js"
import type { AISDKDomain } from "./aisdk.js" import type { AISDKDomain } from "./aisdk.js"
import type { CatalogDomain } from "./catalog.js" import type { CatalogDomain } from "./catalog.js"
import type { CommandDomain } from "./command.js" import type { CommandDomain } from "./command.js"
@ -16,7 +15,6 @@ import type { ToolDomain } from "./tool.js"
export interface Context { export interface Context {
readonly options: PluginOptions readonly options: PluginOptions
readonly agent: AgentDomain readonly agent: AgentDomain
readonly ai: AIDomain
readonly aisdk: AISDKDomain readonly aisdk: AISDKDomain
readonly catalog: CatalogDomain readonly catalog: CatalogDomain
readonly command: CommandDomain readonly command: CommandDomain

View file

@ -1,3 +1,24 @@
import type { SessionApi } from "@opencode-ai/client/effect/api" import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export type SessionDomain = Pick<SessionApi<unknown>, "create" | "get" | "prompt" | "command" | "interrupt"> export interface SessionContext {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
system: Array<SystemPart>
messages: Array<Message>
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHooks {
readonly context: SessionContext
}
export type SessionDomain = Pick<SessionApi<unknown>, "create" | "get" | "prompt" | "command" | "interrupt"> & {
readonly hook: Hooks<SessionHooks>
}

View file

@ -85,10 +85,10 @@ await ctx.aisdk.hook("language", (event) => {
}) })
``` ```
AI request context is mutable immediately before provider dispatch: Session context is mutable immediately before provider dispatch:
```ts ```ts
await ctx.ai.hook("request", (event) => { await ctx.session.hook("context", (event) => {
event.tools.read.description = "Read a file using narrow line ranges." event.tools.read.description = "Read a file using narrow line ranges."
delete event.tools.write delete event.tools.write
}) })

View file

@ -1,23 +0,0 @@
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export interface AIRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
system: Array<SystemPart>
messages: Array<Message>
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface AIHooks {
readonly request: AIRequest
}
export interface AIDomain {
readonly hook: Hooks<AIHooks>
}

View file

@ -1,7 +1,6 @@
import type { PluginApi } from "@opencode-ai/client/promise/api" import type { PluginApi } from "@opencode-ai/client/promise/api"
import type { PluginOptions } from "../options.js" import type { PluginOptions } from "../options.js"
import type { AgentDomain } from "./agent.js" import type { AgentDomain } from "./agent.js"
import type { AIDomain } from "./ai.js"
import type { AISDKDomain } from "./aisdk.js" import type { AISDKDomain } from "./aisdk.js"
import type { CatalogDomain } from "./catalog.js" import type { CatalogDomain } from "./catalog.js"
import type { CommandDomain } from "./command.js" import type { CommandDomain } from "./command.js"
@ -15,7 +14,6 @@ import type { ToolDomain } from "./tool.js"
export interface Context { export interface Context {
readonly options: PluginOptions readonly options: PluginOptions
readonly agent: AgentDomain readonly agent: AgentDomain
readonly ai: AIDomain
readonly aisdk: AISDKDomain readonly aisdk: AISDKDomain
readonly catalog: CatalogDomain readonly catalog: CatalogDomain
readonly command: CommandDomain readonly command: CommandDomain

View file

@ -1,3 +1,24 @@
import type { SessionApi } from "@opencode-ai/client/promise/api" import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export type SessionDomain = Pick<SessionApi, "create" | "get" | "prompt" | "command" | "interrupt"> export interface SessionContext {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
system: Array<SystemPart>
messages: Array<Message>
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHooks {
readonly context: SessionContext
}
export type SessionDomain = Pick<SessionApi, "create" | "get" | "prompt" | "command" | "interrupt"> & {
readonly hook: Hooks<SessionHooks>
}