feat(plugin): restore ai request hook

This commit is contained in:
Dax Raad 2026-07-15 15:58:14 -04:00
commit f2f5eb6f16
19 changed files with 261 additions and 10 deletions

View file

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

View file

@ -0,0 +1,45 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
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"
import { testEffect } from "./lib/effect"
const layer = PluginHooks.node.implementation as Layer.Layer<PluginHooks.Service>
const it = testEffect(layer)
describe("PluginHooks", () => {
it.effect("registers scoped AI hooks and triggers them sequentially", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const seen: string[] = []
yield* hooks.register("ai", "request", (event) =>
Effect.sync(() => {
seen.push("first")
event.system.push(SystemPart.make("second"))
}),
)
yield* hooks.register("ai", "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("ai", "request", event)).toBe(event)
expect(seen).toEqual(["first", "second"])
expect(event.messages).toEqual([Message.user("changed")])
}),
)
})

View file

@ -18,6 +18,9 @@ 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"),
},

View file

@ -1,13 +1,18 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
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 { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@ -72,6 +77,38 @@ describe("fromPromise", () => {
}),
)
it.effect("forwards AI request 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",
setup: async (ctx) => {
await ctx.ai.hook("request", (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"),
agent: AgentV2.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
system: [SystemPart.make("Initial")],
messages: [Message.user("Hello")],
tools: { echo: { description: "Echo", input: { type: "object" } } },
}
yield* hooks.trigger("ai", "request", event)
expect(event.system.map((part) => part.text)).toEqual(["Initial", "Promise hook"])
expect(event.tools).toEqual({})
}),
)
it.effect("disposes a hook registration on request", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service

View file

@ -3,7 +3,9 @@ import {
LLMClient,
LLMError,
LLMEvent,
Message,
Model,
SystemPart,
ToolFailure,
TransportReason,
InvalidProviderOutputReason,
@ -42,6 +44,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { QuestionTool } from "@opencode-ai/core/tool/question"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { AgentV2 } from "@opencode-ai/core/agent"
@ -397,6 +400,7 @@ const it = testEffect(
AgentV2.node,
ToolRegistry.node,
ToolRegistry.toolsNode,
PluginHooks.node,
echoNode,
SessionRunnerModel.node,
InstructionBuiltIns.node,
@ -773,6 +777,32 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
})
describe("SessionRunnerLLM", () => {
it.effect("applies AI request hooks without exposing unavailable tools", () =>
Effect.gen(function* () {
const session = yield* setup
const hooks = yield* PluginHooks.Service
yield* hooks.register("ai", "request", (event) =>
Effect.sync(() => {
event.system = [SystemPart.make("Hooked system")]
event.messages = [Message.user("Hooked message")]
delete event.tools.echo
event.tools.unregistered = { description: "Unavailable", input: { type: "object" } }
}),
)
yield* admit(session, "Original message")
responses = [reply.tool("call-removed", "echo", { text: "blocked" })]
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Hooked system"])
expect(requests[0]?.messages).toEqual([Message.user("Hooked message")])
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("unregistered")
expect(executions).toEqual([])
}),
)
it.effect("advertises and executes a location registered tool", () =>
Effect.gen(function* () {
const session = yield* setup