From f92d84746b64771319c628cc245064c885cd4ca1 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:32:00 -0400 Subject: [PATCH] refactor(plugin): scope context hook to session (#37175) Co-authored-by: Dax Raad --- packages/core/src/plugin/hooks.ts | 4 ++-- packages/core/src/plugin/host.ts | 4 +--- packages/core/src/plugin/promise.ts | 6 ++---- packages/core/src/session/runner/llm.ts | 12 ++++++------ packages/core/src/tool/patch.ts | 2 +- packages/core/src/tool/subagent.ts | 2 +- packages/core/test/lib/tool.ts | 2 +- packages/core/test/plugin-hooks.test.ts | 8 ++++---- packages/core/test/plugin/host.ts | 20 ++++++++++---------- packages/core/test/plugin/promise.test.ts | 14 +++++++------- packages/core/test/session-runner.test.ts | 4 ++-- packages/plugin/src/v2/effect/README.md | 4 ++-- packages/plugin/src/v2/effect/ai.ts | 23 ----------------------- packages/plugin/src/v2/effect/plugin.ts | 2 -- packages/plugin/src/v2/effect/session.ts | 23 ++++++++++++++++++++++- packages/plugin/src/v2/promise/README.md | 4 ++-- packages/plugin/src/v2/promise/ai.ts | 23 ----------------------- packages/plugin/src/v2/promise/plugin.ts | 2 -- packages/plugin/src/v2/promise/session.ts | 23 ++++++++++++++++++++++- 19 files changed, 85 insertions(+), 97 deletions(-) delete mode 100644 packages/plugin/src/v2/effect/ai.ts delete mode 100644 packages/plugin/src/v2/promise/ai.ts diff --git a/packages/core/src/plugin/hooks.ts b/packages/core/src/plugin/hooks.ts index 5c36938612..4ac437d392 100644 --- a/packages/core/src/plugin/hooks.ts +++ b/packages/core/src/plugin/hooks.ts @@ -1,15 +1,15 @@ 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 { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool" import { Context, Effect, Layer, Scope } from "effect" import { makeLocationNode } from "../effect/app-node" import { State } from "../state" export interface Domains { - readonly ai: AIHooks readonly aisdk: AISDKHooks + readonly session: SessionHooks readonly tool: ToolHooks } diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 207a0cb1e1..5831991df1 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -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: { hook: (name, callback) => { if (name === "sdk") { @@ -370,6 +367,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }, }, session: { + hook: (name, callback) => hooks.register("session", name, callback), create: (input) => runtime.session.create({ id: input?.id, diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 670964b3ca..72adc9ec35 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -63,10 +63,6 @@ export function fromPromise(plugin: Plugin) { transform: transform(host.agent), reload: () => run(host.agent.reload()), }, - ai: { - hook: (name, callback) => - register(host.ai.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), - }, aisdk: { hook: (name, callback) => 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))))), }, session: { + hook: (name, callback) => + register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), create: (input) => run( host.session.create( diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 3a7ab625bc..d6c896ec31 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -10,7 +10,7 @@ import { isContextOverflowFailure, type ProviderErrorEvent, } 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 { Money } from "@opencode-ai/schema/money" import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" @@ -216,7 +216,7 @@ const layer = Layer.effect( toolChoice: isLastStep ? "none" : undefined, }) const availableTools = new Map(request.tools.map((tool) => [tool.name, tool])) - const requestEvent: AIHooks["request"] = { + const contextEvent: SessionHooks["context"] = { sessionID: session.id, agent: agent.id, model: resolved.ref, @@ -228,11 +228,11 @@ const layer = Layer.effect( } // Plugins may reshape the draft but cannot advertise tools excluded by // 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, { - system: requestEvent.system, - messages: requestEvent.messages, - tools: Object.entries(requestEvent.tools).flatMap(([name, tool]) => { + system: contextEvent.system, + messages: contextEvent.messages, + tools: Object.entries(contextEvent.tools).flatMap(([name, tool]) => { const registered = availableTools.get(name) if (!registered) return [] return [{ ...registered, description: tool.description, inputSchema: tool.input }] diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index 57e8c0ef2a..6925b87793 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -196,7 +196,7 @@ export const Plugin = { ) .pipe(Effect.orDie) - yield* ctx.ai.hook("request", (event) => + yield* ctx.session.hook("context", (event) => Effect.sync(() => { const usePatch = event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt") diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 0cc2de9db5..3043ab7ddc 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -216,7 +216,7 @@ export const Plugin = { ) .pipe(Effect.orDie) - yield* ctx.ai.hook("request", (event) => + yield* ctx.session.hook("context", (event) => Effect.gen(function* () { const tool = event.tools[name] if (!tool) return diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 9adf3c240b..e3fdccb13a 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -44,7 +44,7 @@ export const registerToolPlugin = (plugin: { Effect.gen(function* () { const tools = yield* Tools.Service const context = host({ - ai: { + session: { hook: () => Effect.succeed({ dispose: Effect.void }), }, tool: { diff --git a/packages/core/test/plugin-hooks.test.ts b/packages/core/test/plugin-hooks.test.ts index 1223aa0c94..4fdf9a9c54 100644 --- a/packages/core/test/plugin-hooks.test.ts +++ b/packages/core/test/plugin-hooks.test.ts @@ -12,17 +12,17 @@ const layer = PluginHooks.node.implementation as Layer.Layer { - it.effect("registers scoped AI hooks and triggers them sequentially", () => + it.effect("registers scoped session hooks and triggers them sequentially", () => Effect.gen(function* () { const hooks = yield* PluginHooks.Service const seen: string[] = [] - yield* hooks.register("ai", "request", (event) => + yield* hooks.register("session", "context", (event) => Effect.sync(() => { seen.push("first") event.system.push(SystemPart.make("second")) }), ) - yield* hooks.register("ai", "request", (event) => + yield* hooks.register("session", "context", (event) => Effect.sync(() => { seen.push(event.system[1]?.text ?? "missing") event.messages = [Message.user("changed")] @@ -37,7 +37,7 @@ describe("PluginHooks", () => { 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(event.messages).toEqual([Message.user("changed")]) }), diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index ae7c66ec83..8dafec5025 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -8,7 +8,9 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" -type Overrides = Partial> +type Overrides = Partial> & { + readonly session?: Partial +} export function host(overrides: Overrides = {}): PluginContext { return { @@ -18,9 +20,6 @@ export function host(overrides: Overrides = {}): PluginContext { transform: () => Effect.die("unused agent.transform"), reload: () => Effect.die("unused agent.reload"), }, - ai: overrides.ai ?? { - hook: () => Effect.die("unused ai.hook"), - }, aisdk: overrides.aisdk ?? { hook: () => Effect.die("unused aisdk.hook"), }, @@ -80,12 +79,13 @@ export function host(overrides: Overrides = {}): PluginContext { transform: () => Effect.die("unused tool.transform"), hook: () => Effect.die("unused tool.hook"), }, - session: overrides.session ?? { - create: () => Effect.die("unused session.create"), - get: () => Effect.die("unused session.get"), - prompt: () => Effect.die("unused session.prompt"), - command: () => Effect.die("unused session.command"), - interrupt: () => Effect.die("unused session.interrupt"), + session: { + hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")), + create: overrides.session?.create ?? (() => Effect.die("unused session.create")), + get: overrides.session?.get ?? (() => Effect.die("unused session.get")), + prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")), + command: overrides.session?.command ?? (() => Effect.die("unused session.command")), + interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")), }, } } diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index e7577faa6a..b0250b1b25 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -10,7 +10,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolRegistry } from "@opencode-ai/core/tool/registry" 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 { Provider } from "@opencode-ai/schema/provider" 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* () { const plugin = yield* PluginV2.Service const hooks = yield* PluginHooks.Service const host = yield* PluginHost.make(plugin) yield* PluginPromise.fromPromise( Plugin.define({ - id: "promise-ai-request", + id: "promise-session-context", setup: async (ctx) => { - await ctx.ai.hook("request", (event) => { + await ctx.session.hook("context", (event) => { event.system.push(SystemPart.make("Promise hook")) delete event.tools.echo }) }, }), ).effect(host) - const event: AIHooks["request"] = { - sessionID: SessionV2.ID.make("ses_promise_ai_request"), + const event: SessionHooks["context"] = { + sessionID: SessionV2.ID.make("ses_promise_session_context"), agent: AgentV2.ID.make("build"), model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), system: [SystemPart.make("Initial")], @@ -102,7 +102,7 @@ describe("fromPromise", () => { 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.tools).toEqual({}) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 2585355fe7..6c35f02d87 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -777,11 +777,11 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => }) 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* () { const session = yield* setup const hooks = yield* PluginHooks.Service - yield* hooks.register("ai", "request", (event) => + yield* hooks.register("session", "context", (event) => Effect.sync(() => { event.system = [SystemPart.make("Hooked system")] event.messages = [Message.user("Hooked message")] diff --git a/packages/plugin/src/v2/effect/README.md b/packages/plugin/src/v2/effect/README.md index 50ee83615b..8ce7ddb414 100644 --- a/packages/plugin/src/v2/effect/README.md +++ b/packages/plugin/src/v2/effect/README.md @@ -80,11 +80,11 @@ yield * 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 yield * - ctx.ai.hook("request", (event) => + ctx.session.hook("context", (event) => Effect.sync(() => { event.tools.read.description = "Read a file using narrow line ranges." delete event.tools.write diff --git a/packages/plugin/src/v2/effect/ai.ts b/packages/plugin/src/v2/effect/ai.ts deleted file mode 100644 index 306d53cd17..0000000000 --- a/packages/plugin/src/v2/effect/ai.ts +++ /dev/null @@ -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 - messages: Array - tools: Record -} - -export interface AIHooks { - readonly request: AIRequest -} - -export interface AIDomain { - readonly hook: Hooks -} diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 7be0b228e1..3b0b6eb918 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -2,7 +2,6 @@ import type { PluginApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" import type { PluginOptions } from "../options.js" import type { AgentDomain } from "./agent.js" -import type { AIDomain } from "./ai.js" import type { AISDKDomain } from "./aisdk.js" import type { CatalogDomain } from "./catalog.js" import type { CommandDomain } from "./command.js" @@ -16,7 +15,6 @@ import type { ToolDomain } from "./tool.js" export interface Context { readonly options: PluginOptions readonly agent: AgentDomain - readonly ai: AIDomain readonly aisdk: AISDKDomain readonly catalog: CatalogDomain readonly command: CommandDomain diff --git a/packages/plugin/src/v2/effect/session.ts b/packages/plugin/src/v2/effect/session.ts index 40da735cbe..541a8da28c 100644 --- a/packages/plugin/src/v2/effect/session.ts +++ b/packages/plugin/src/v2/effect/session.ts @@ -1,3 +1,24 @@ 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, "create" | "get" | "prompt" | "command" | "interrupt"> +export interface SessionContext { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + system: Array + messages: Array + tools: Record +} + +export interface SessionHooks { + readonly context: SessionContext +} + +export type SessionDomain = Pick, "create" | "get" | "prompt" | "command" | "interrupt"> & { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/promise/README.md b/packages/plugin/src/v2/promise/README.md index 268a98a94b..e23a231e4f 100644 --- a/packages/plugin/src/v2/promise/README.md +++ b/packages/plugin/src/v2/promise/README.md @@ -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 -await ctx.ai.hook("request", (event) => { +await ctx.session.hook("context", (event) => { event.tools.read.description = "Read a file using narrow line ranges." delete event.tools.write }) diff --git a/packages/plugin/src/v2/promise/ai.ts b/packages/plugin/src/v2/promise/ai.ts deleted file mode 100644 index 306d53cd17..0000000000 --- a/packages/plugin/src/v2/promise/ai.ts +++ /dev/null @@ -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 - messages: Array - tools: Record -} - -export interface AIHooks { - readonly request: AIRequest -} - -export interface AIDomain { - readonly hook: Hooks -} diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index 1928ca3672..2e05b1fded 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -1,7 +1,6 @@ import type { PluginApi } from "@opencode-ai/client/promise/api" import type { PluginOptions } from "../options.js" import type { AgentDomain } from "./agent.js" -import type { AIDomain } from "./ai.js" import type { AISDKDomain } from "./aisdk.js" import type { CatalogDomain } from "./catalog.js" import type { CommandDomain } from "./command.js" @@ -15,7 +14,6 @@ import type { ToolDomain } from "./tool.js" export interface Context { readonly options: PluginOptions readonly agent: AgentDomain - readonly ai: AIDomain readonly aisdk: AISDKDomain readonly catalog: CatalogDomain readonly command: CommandDomain diff --git a/packages/plugin/src/v2/promise/session.ts b/packages/plugin/src/v2/promise/session.ts index c521c8fddd..b2f35c867b 100644 --- a/packages/plugin/src/v2/promise/session.ts +++ b/packages/plugin/src/v2/promise/session.ts @@ -1,3 +1,24 @@ 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 +export interface SessionContext { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + system: Array + messages: Array + tools: Record +} + +export interface SessionHooks { + readonly context: SessionContext +} + +export type SessionDomain = Pick & { + readonly hook: Hooks +}