diff --git a/.changeset/v2-operation-hooks.md b/.changeset/v2-operation-hooks.md new file mode 100644 index 000000000000..a63943eb1ba7 --- /dev/null +++ b/.changeset/v2-operation-hooks.md @@ -0,0 +1,5 @@ +--- +"opencode": minor +--- + +Add Location-scoped V2 operation hooks with waterfall, serial, and parallel dispatch, deterministic plugin ordering, scoped disposal, and real operation payloads. Migrate agent, command, compaction, permission, text, tool, and turn lifecycle hooks off the EventV2 placeholder dispatch. diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts index d8ace63b3dd8..9e815b1ca84d 100644 --- a/packages/core/src/config/plugin/external.ts +++ b/packages/core/src/config/plugin/external.ts @@ -86,6 +86,6 @@ export const Plugin = define({ }) }).pipe(Effect.ignoreCause) } - }).pipe(Effect.forkScoped({ startImmediately: true })) + }) }), }) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index acdd595b11d2..c92ac0ac2ce3 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -145,36 +145,6 @@ export interface Interface { ) => Effect.Effect readonly remove: (aggregateID: string) => Effect.Effect readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect - /** - * Waterfall dispatch: listeners run sequentially in registration order. Each - * listener receives the event and a `next()` callback that delegates to the - * downstream listener. Skipping `next()` short-circuits and the listener's - * return value replaces whatever `next` would have produced. Mirrors the - * dsh `ctx.waterfall` semantics and is the right shape for `tool.execute.before`, - * `command.execute.before`, and `experimental.chat.messages.transform`. - */ - readonly waterfall: ( - definition: D, - listeners: ReadonlyArray<(event: Payload, next: () => Effect.Effect) => Effect.Effect>, - ) => Effect.Effect - /** - * Serial dispatch: listeners run sequentially in registration order, each - * awaits the previous, and the dispatch collects every listener's value. Use - * when every listener must run (decision aggregation, telemetry stages). - */ - readonly serial: ( - definition: D, - listeners: ReadonlyArray<(event: Payload) => Effect.Effect>, - ) => Effect.Effect> - /** - * Parallel dispatch: listeners run concurrently with `Effect.forEach`'s - * concurrency=unbounded. Returns every listener's value, never short-circuits. - * Use for observation-only subscribers that must not block each other. - */ - readonly parallel: ( - definition: D, - listeners: ReadonlyArray<(event: Payload) => Effect.Effect>, - ) => Effect.Effect> } export class Service extends Context.Service()("@opencode/Event") {} @@ -649,55 +619,6 @@ export const layerWith = (options?: LayerOptions) => projectors.set(definition.type, list) }) - /** - * `waterfall`/`serial`/`parallel` don't take a published event; the - * listeners synthesise the shape they need. We pass a placeholder with - * the right `type` so `Payload` typechecks; listeners that read fields - * are expected to gate themselves on the dispatch they subscribed to. - */ - const placeholderEvent = (definition: D): Payload => - ({ id: ID.create(), type: definition.type, data: undefined }) as Payload - - const waterfall = ( - _definition: D, - listeners: ReadonlyArray<(event: Payload, next: () => Effect.Effect) => Effect.Effect>, - ): Effect.Effect => { - if (listeners.length === 0) { - return Effect.die(new Error(`waterfall called with zero listeners`)) - } - const head = listeners[0]! - const tail = listeners.slice(1) - return Effect.suspend(() => { - const event = placeholderEvent(_definition) - return head(event, () => - tail.length === 0 - ? Effect.succeed(event as unknown as R) - : waterfall(_definition, tail), - ) - }) - } - - const serial = ( - definition: D, - listeners: ReadonlyArray<(event: Payload) => Effect.Effect>, - ): Effect.Effect> => - Effect.suspend(() => { - const event = placeholderEvent(definition) - return Effect.forEach(listeners, (listener) => listener(event)) - }) - - const parallel = ( - definition: D, - listeners: ReadonlyArray<(event: Payload) => Effect.Effect>, - ): Effect.Effect> => - Effect.suspend(() => { - const event = placeholderEvent(definition) - return Effect.forEach(listeners, (listener) => listener(event), { - concurrency: "unbounded", - discard: false, - }) - }) - return Service.of({ publish, subscribe, @@ -706,9 +627,6 @@ export const layerWith = (options?: LayerOptions) => listen, project, replay, - waterfall, - serial, - parallel, replayAll, remove, claim, diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index a58943a4da78..8e9da8d5352f 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -16,6 +16,7 @@ import { Integration } from "./integration" import { RuntimeInvariant } from "./invariant" import { Location } from "./location" import { LocationMutation } from "./location-mutation" +import { OperationHook } from "./operation-hook" import { LocationServiceMap } from "./location-service-map" import { PermissionV2 } from "./permission" import { PluginV2 } from "./plugin" @@ -72,6 +73,7 @@ export const locationServices = LayerNode.group([ SystemContextRegistry.node, SystemContextBuiltIns.node, LocationMutation.node, + OperationHook.node, FileMutation.node, PermissionV2.node, ToolOutputStore.node, diff --git a/packages/core/src/operation-hook.ts b/packages/core/src/operation-hook.ts new file mode 100644 index 000000000000..a3766329d173 --- /dev/null +++ b/packages/core/src/operation-hook.ts @@ -0,0 +1,173 @@ +export * as OperationHook from "./operation-hook" + +import { + Definition, + Operation, + type Data, + type Hooks, + type Observer, + type ParallelDefinition, + type Payload, + type SerialDefinition, + type WaterfallDefinition, + type WaterfallHandler, +} from "@opencode-ai/plugin/v2/effect/operation-hook" +import { Context, Effect, Layer, Scope } from "effect" +import { makeLocationNode } from "./effect/app-node" +import { EventV2 } from "./event" +import { Location } from "./location" + +export { Definition, Operation } + +export interface Interface { + readonly register: Hooks + readonly waterfall: (definition: D, data: Data) => Effect.Effect> + readonly serial: (definition: D, data: Data) => Effect.Effect + readonly parallel: (definition: D, data: Data) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/OperationHook") {} + +type InternalPayload = { + readonly id: string + readonly type: string + readonly location: Location.Ref + readonly data: unknown +} + +type WaterfallEntry = { + readonly run: (event: InternalPayload, next: (data?: unknown) => Effect.Effect) => Effect.Effect +} + +type ObserverEntry = { + readonly run: (event: InternalPayload) => Effect.Effect +} + +export const locationLayer = Layer.effect( + Service, + Effect.gen(function* () { + const location = yield* Location.Service + const waterfallEntries = new Map() + const serialEntries = new Map() + const parallelEntries = new Map() + + const add = (entries: Map, type: string, entry: Entry) => + Effect.gen(function* () { + const scope = yield* Scope.Scope + let active = true + const dispose = Effect.uninterruptible( + Effect.sync(() => { + if (!active) return + active = false + entries.set(type, (entries.get(type) ?? []).filter((item) => item !== entry)) + if (entries.get(type)?.length === 0) entries.delete(type) + }), + ) + + yield* Effect.uninterruptible( + Effect.sync(() => entries.set(type, [...(entries.get(type) ?? []), entry])).pipe( + Effect.andThen(Scope.addFinalizer(scope, dispose)), + ), + ) + return { dispose } + }) + + const waterfall = (definition: D, data: Data) => + Effect.gen(function* () { + requireMode(definition, "waterfall") + const entries = [...(waterfallEntries.get(definition.type) ?? [])] + const base = { + id: EventV2.ID.create(), + type: definition.type, + location, + } + const run = (index: number, current: unknown): Effect.Effect => { + const entry = entries[index] + if (!entry) return Effect.succeed(current) + return entry.run({ ...base, data: current }, (next = current) => run(index + 1, next)) + } + return (yield* run(0, data)) as Data + }) + + const observe = (event: InternalPayload, entry: ObserverEntry) => + entry.run(event).pipe( + Effect.catchCause((cause) => + Effect.logError("Operation hook failed", { eventID: event.id, eventType: event.type, cause }), + ), + ) + + const dispatchObservers = ( + mode: "serial" | "parallel", + entries: Map, + definition: D, + data: Data, + ) => + Effect.gen(function* () { + requireMode(definition, mode) + const event = { + id: EventV2.ID.create(), + type: definition.type, + location, + data, + } + yield* Effect.forEach(entries.get(definition.type) ?? [], (entry) => observe(event, entry), { + concurrency: mode === "parallel" ? "unbounded" : 1, + discard: true, + }) + }) + + const registerWaterfall = (definition: D, callback: WaterfallHandler) => { + requireMode(definition, "waterfall") + const entry: WaterfallEntry = { + run(event, next) { + if (event.type !== definition.type) return Effect.die(new Error(`Operation hook type mismatch`)) + const payload = { ...event, type: definition.type, data: event.data as Data } satisfies Payload + return Effect.suspend(() => { + const result = callback(payload, (data) => next(data).pipe(Effect.map((value) => value as Data))) + return Effect.isEffect(result) ? result : Effect.succeed(result) + }) + }, + } + return add(waterfallEntries, definition.type, entry) + } + + const registerObserver = ( + mode: "serial" | "parallel", + entries: Map, + definition: D, + callback: Observer, + ) => { + requireMode(definition, mode) + const entry: ObserverEntry = { + run(event) { + if (event.type !== definition.type) return Effect.die(new Error(`Operation hook type mismatch`)) + const payload = { ...event, type: definition.type, data: event.data as Data } satisfies Payload + return Effect.suspend(() => { + const result = callback(payload) + return Effect.isEffect(result) ? result : Effect.void + }) + }, + } + return add(entries, definition.type, entry) + } + + return Service.of({ + register: { + waterfall: registerWaterfall, + serial: (definition, callback) => registerObserver("serial", serialEntries, definition, callback), + parallel: (definition, callback) => registerObserver("parallel", parallelEntries, definition, callback), + }, + waterfall, + serial: (definition, data) => dispatchObservers("serial", serialEntries, definition, data), + parallel: (definition, data) => dispatchObservers("parallel", parallelEntries, definition, data), + }) + }), +) + +function requireMode(definition: Definition, mode: Definition["mode"]) { + if (definition.mode !== mode) { + throw new Error(`Operation hook ${definition.type} uses ${definition.mode}, not ${mode}`) + } +} + +export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Location.node] }) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 21e9cb4c50d8..58b34ac68222 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -11,6 +11,7 @@ import { Catalog } from "./catalog" import { CommandV2 } from "./command" import { EventV2 } from "./event" import { Integration } from "./integration" +import { OperationHook } from "./operation-hook" import { KeyedMutex } from "./effect/keyed-mutex" import { PluginHost } from "./plugin/host" import { Reference } from "./reference" @@ -180,6 +181,7 @@ export const locationLayer = layer.pipe( Layer.provideMerge(Catalog.locationLayer), Layer.provideMerge(CommandV2.locationLayer), Layer.provideMerge(Integration.locationLayer), + Layer.provideMerge(OperationHook.locationLayer), Layer.provideMerge(Reference.locationLayer), ) @@ -195,6 +197,7 @@ export const node = makeLocationNode({ Catalog.node, CommandV2.node, Integration.node, + OperationHook.node, Reference.node, SkillV2.node, ], diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 67a69ecdd63a..eb30ae8cb4d1 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -10,6 +10,7 @@ import { CommandV2 } from "../command" import { Credential } from "../credential" import { Integration } from "../integration" import { ModelV2 } from "../model" +import { OperationHook } from "../operation-hook" import { PluginV2 } from "../plugin" import { ProviderV2 } from "../provider" import { Reference } from "../reference" @@ -25,6 +26,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service const integration = yield* Integration.Service + const operationHooks = yield* OperationHook.Service const reference = yield* Reference.Service const skill = yield* SkillV2.Service @@ -254,6 +256,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }), ), }, + hook: operationHooks.register, plugin: { add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect), remove: (id) => plugin.remove(PluginV2.ID.make(id)), diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index d0bf82c2a0c0..ab000ea1801b 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -70,6 +70,29 @@ export function fromPromise(plugin: Plugin) { resolve: (connection) => Effect.runPromiseWith(context)(host.integration.connection.resolve(connection)), }, }, + hook: { + waterfall: (definition, callback) => + register( + host.hook.waterfall(definition, (event, next) => + Effect.gen(function* () { + const dispatchContext = yield* Effect.context() + return yield* Effect.promise((signal) => + Promise.resolve( + callback(event, (data) => Effect.runPromiseWith(dispatchContext)(next(data), { signal })), + ), + ) + }), + ), + ), + serial: (definition, callback) => + register( + host.hook.serial(definition, (event) => Effect.promise((_signal) => Promise.resolve(callback(event)))), + ), + parallel: (definition, callback) => + register( + host.hook.parallel(definition, (event) => Effect.promise((_signal) => Promise.resolve(callback(event)))), + ), + }, plugin: { add: (input) => { const child = fromPromise(input) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index 4396a792e893..e4329a2dde98 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -268,57 +268,6 @@ describe("EventV2", () => { }), ) - it.effect("runs serial dispatch listeners one at a time", () => - Effect.gen(function* () { - const events = yield* EventV2.Service - const firstStarted = yield* Deferred.make() - const releaseFirst = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - const dispatch = yield* events - .serial(Message, [ - () => - Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseFirst)), - Effect.as("first"), - ), - () => Deferred.succeed(secondStarted, undefined).pipe(Effect.as("second")), - ]) - .pipe(Effect.forkChild) - - yield* Deferred.await(firstStarted) - yield* Effect.yieldNow - expect(yield* Deferred.isDone(secondStarted)).toBe(false) - - yield* Deferred.succeed(releaseFirst, undefined) - expect(yield* Fiber.join(dispatch)).toEqual(["first", "second"]) - expect(yield* Deferred.isDone(secondStarted)).toBe(true) - }), - ) - - it.effect("runs parallel dispatch listeners concurrently", () => - Effect.gen(function* () { - const events = yield* EventV2.Service - const firstStarted = yield* Deferred.make() - const releaseFirst = yield* Deferred.make() - const secondStarted = yield* Deferred.make() - const dispatch = yield* events - .parallel(Message, [ - () => - Deferred.succeed(firstStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseFirst)), - Effect.as("first"), - ), - () => Deferred.succeed(secondStarted, undefined).pipe(Effect.as("second")), - ]) - .pipe(Effect.forkChild) - - yield* Deferred.await(firstStarted) - yield* Deferred.await(secondStarted) - yield* Deferred.succeed(releaseFirst, undefined) - expect(yield* Fiber.join(dispatch)).toEqual(["first", "second"]) - }), - ) - it.effect("isolates observer defects after durable events commit", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/core/test/operation-hook.test.ts b/packages/core/test/operation-hook.test.ts new file mode 100644 index 000000000000..77c06c8b2b6b --- /dev/null +++ b/packages/core/test/operation-hook.test.ts @@ -0,0 +1,183 @@ +import { describe, expect } from "bun:test" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Location } from "@opencode-ai/core/location" +import { OperationHook } from "@opencode-ai/core/operation-hook" +import { DateTime, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect" +import { location } from "./fixture/location" +import { testEffect } from "./lib/effect" + +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location(Location.Ref.make({ directory: AbsolutePath.make("project") }))), +) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Location.node, OperationHook.node]), [[Location.node, locationLayer]]), +) + +const text = (value: string, timestamp: DateTime.Utc) => ({ + timestamp, + sessionID: "session", + messageID: "message", + partID: "part", + text: value, +}) + +describe("OperationHook", () => { + it.effect("returns waterfall input when no handlers are registered", () => + Effect.gen(function* () { + const hooks = yield* OperationHook.Service + const input = text("original", yield* DateTime.now) + + expect(yield* hooks.waterfall(OperationHook.Operation.Text.Complete, input)).toEqual(input) + }), + ) + + it.effect("runs waterfall handlers in registration order with one operation identity", () => + Effect.gen(function* () { + const hooks = yield* OperationHook.Service + const order: string[] = [] + const ids: string[] = [] + const locations: string[] = [] + + yield* hooks.register.waterfall(OperationHook.Operation.Text.Complete, (event, next) => + Effect.gen(function* () { + order.push("first:before") + ids.push(event.id) + locations.push(event.location.directory) + const downstream = yield* next({ ...event.data, text: `${event.data.text}:first` }) + order.push("first:after") + return { ...downstream, text: `${downstream.text}:after` } + }), + ) + yield* hooks.register.waterfall(OperationHook.Operation.Text.Complete, (event) => { + order.push("second") + ids.push(event.id) + locations.push(event.location.directory) + return { ...event.data, text: `${event.data.text}:second` } + }) + + const result = yield* hooks.waterfall( + OperationHook.Operation.Text.Complete, + text("original", yield* DateTime.now), + ) + + expect(result.text).toBe("original:first:second:after") + expect(order).toEqual(["first:before", "second", "first:after"]) + expect(new Set(ids).size).toBe(1) + expect(locations).toEqual(["project", "project"]) + }), + ) + + it.effect("short-circuits waterfall handlers when next is skipped", () => + Effect.gen(function* () { + const hooks = yield* OperationHook.Service + let downstream = false + yield* hooks.register.waterfall(OperationHook.Operation.Text.Complete, (event) => ({ + ...event.data, + text: "blocked", + })) + yield* hooks.register.waterfall(OperationHook.Operation.Text.Complete, (event) => { + downstream = true + return event.data + }) + + const result = yield* hooks.waterfall( + OperationHook.Operation.Text.Complete, + text("original", yield* DateTime.now), + ) + + expect(result.text).toBe("blocked") + expect(downstream).toBe(false) + }), + ) + + it.effect("removes registrations when their scope closes", () => + Effect.gen(function* () { + const hooks = yield* OperationHook.Service + const scope = yield* Scope.make() + yield* hooks.register + .waterfall(OperationHook.Operation.Text.Complete, (event) => ({ ...event.data, text: "registered" })) + .pipe(Scope.provide(scope)) + + expect( + (yield* hooks.waterfall(OperationHook.Operation.Text.Complete, text("before", yield* DateTime.now))).text, + ).toBe("registered") + yield* Scope.close(scope, Exit.void) + expect( + (yield* hooks.waterfall(OperationHook.Operation.Text.Complete, text("after", yield* DateTime.now))).text, + ).toBe("after") + }), + ) + + it.effect("runs serial observers one at a time", () => + Effect.gen(function* () { + const hooks = yield* OperationHook.Service + const definition = new OperationHook.Definition<"test.serial", { value: string }, "serial">( + "test.serial", + "serial", + ) + const firstStarted = yield* Deferred.make() + const releaseFirst = yield* Deferred.make() + const secondStarted = yield* Deferred.make() + yield* hooks.register.serial(definition, () => + Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))), + ) + yield* hooks.register.serial(definition, () => Deferred.succeed(secondStarted, undefined)) + const dispatch = yield* hooks.serial(definition, { value: "test" }).pipe(Effect.forkChild) + + yield* Deferred.await(firstStarted) + yield* Effect.yieldNow + expect(yield* Deferred.isDone(secondStarted)).toBe(false) + yield* Deferred.succeed(releaseFirst, undefined) + yield* Fiber.join(dispatch) + expect(yield* Deferred.isDone(secondStarted)).toBe(true) + }), + ) + + it.effect("runs parallel observers concurrently", () => + Effect.gen(function* () { + const hooks = yield* OperationHook.Service + const firstStarted = yield* Deferred.make() + const releaseFirst = yield* Deferred.make() + const secondStarted = yield* Deferred.make() + yield* hooks.register.parallel(OperationHook.Operation.Turn.Started, () => + Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))), + ) + yield* hooks.register.parallel(OperationHook.Operation.Turn.Started, () => + Deferred.succeed(secondStarted, undefined), + ) + const dispatch = yield* hooks + .parallel(OperationHook.Operation.Turn.Started, { + timestamp: yield* DateTime.now, + sessionID: "session", + }) + .pipe(Effect.forkChild) + + yield* Deferred.await(firstStarted) + yield* Deferred.await(secondStarted) + yield* Deferred.succeed(releaseFirst, undefined) + yield* Fiber.join(dispatch) + }), + ) + + it.effect("isolates observer defects and interruption", () => + Effect.gen(function* () { + const hooks = yield* OperationHook.Service + let observed = false + yield* hooks.register.parallel(OperationHook.Operation.Turn.Started, () => Effect.die("broken observer")) + yield* hooks.register.parallel(OperationHook.Operation.Turn.Started, () => Effect.interrupt) + yield* hooks.register.parallel(OperationHook.Operation.Turn.Started, () => { + observed = true + }) + + yield* hooks.parallel(OperationHook.Operation.Turn.Started, { + timestamp: yield* DateTime.now, + sessionID: "session", + }) + + expect(observed).toBe(true) + }), + ) +}) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 89d36738f35d..ea4ea498c38e 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,8 +1,9 @@ import { describe, expect } from "bun:test" -import { Effect, Exit, Fiber } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { DateTime, Effect, Exit, Fiber } from "effect" +import { define, Operation } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { PluginV2 } from "@opencode-ai/core/plugin" +import { OperationHook } from "@opencode-ai/core/operation-hook" import { testEffect } from "./lib/effect" import { PluginTestLayer } from "./plugin/fixture" @@ -84,4 +85,34 @@ describe("PluginV2", () => { expect(yield* plugins.list()).toEqual([first, second]) }), ) + + it.effect("disposes operation hooks when a plugin is removed", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const hooks = yield* OperationHook.Service + const id = PluginV2.ID.make("operation-hook") + yield* plugins.add( + id, + define({ + id, + effect: (ctx) => + ctx.hook + .waterfall(Operation.Text.Complete, (event) => ({ ...event.data, text: "plugin" })) + .pipe(Effect.asVoid), + }).effect, + ) + + const input = { + timestamp: yield* DateTime.now, + sessionID: "session", + messageID: "message", + partID: "part", + text: "original", + } + expect((yield* hooks.waterfall(Operation.Text.Complete, input)).text).toBe("plugin") + + yield* plugins.remove(id) + expect((yield* hooks.waterfall(Operation.Text.Complete, input)).text).toBe("original") + }), + ) }) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 4c72471bcb19..a79c58a159b0 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -13,6 +13,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Integration } from "@opencode-ai/core/integration" import { Location } from "@opencode-ai/core/location" import { Npm } from "@opencode-ai/core/npm" +import { OperationHook } from "@opencode-ai/core/operation-hook" import { PluginV2 } from "@opencode-ai/core/plugin" import { Reference } from "@opencode-ai/core/reference" import { SkillV2 } from "@opencode-ai/core/skill" @@ -36,6 +37,7 @@ export const PluginTestLayer = AppNodeBuilder.build( FSUtil.node, Location.node, Npm.node, + OperationHook.node, Credential.node, EventV2.node, LayerNodePlatform.httpClient, diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 7ab933b56e64..8d677b604294 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -57,6 +57,11 @@ export function host(overrides: Overrides = {}): PluginContext { resolve: () => Effect.die("unused integration.connection.resolve"), }, }, + hook: overrides.hook ?? { + waterfall: () => Effect.die("unused hook.waterfall"), + serial: () => Effect.die("unused hook.serial"), + parallel: () => Effect.die("unused hook.parallel"), + }, plugin: overrides.plugin ?? { add: () => Effect.die("unused plugin.add"), remove: () => Effect.die("unused plugin.remove"), diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 41a664194642..10ca771f39d2 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -1,10 +1,11 @@ import { describe, expect } from "bun:test" -import { Effect } from "effect" +import { DateTime, Effect } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" +import { OperationHook } from "@opencode-ai/core/operation-hook" 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 { define, Operation } from "@opencode-ai/plugin/v2/promise" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -64,4 +65,33 @@ describe("fromPromise", () => { expect(yield* agents.get(AgentV2.ID.make("temp"))).toBeUndefined() }), ) + + it.effect("composes promise waterfall middleware", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + const hooks = yield* OperationHook.Service + + yield* PluginPromise.fromPromise( + define({ + id: "promise-waterfall", + setup: async (ctx) => { + await ctx.hook.waterfall(Operation.Text.Complete, async (event, next) => { + const result = await next({ ...event.data, text: `${event.data.text}:before` }) + return { ...result, text: `${result.text}:after` } + }) + }, + }), + ).effect(host) + + const result = yield* hooks.waterfall(Operation.Text.Complete, { + timestamp: yield* DateTime.now, + sessionID: "session", + messageID: "message", + partID: "part", + text: "original", + }) + expect(result.text).toBe("original:before:after") + }), + ) }) diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 9c3a9b16fc05..f96ea4dea2b0 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -35,9 +35,6 @@ const capture = () => { replayAll: () => Effect.succeed(undefined), remove: () => Effect.void, claim: () => Effect.void, - waterfall: () => Effect.die("not used in test"), - serial: () => Effect.die("not used in test"), - parallel: () => Effect.die("not used in test"), }) return { published, diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 18763b831617..a1b514ffed98 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -2,8 +2,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Config } from "@/config/config" import { serviceUse } from "@opencode-ai/core/effect/service-use" -import { EventV2Bridge } from "@/event-v2-bridge" -import { SessionEvent } from "@opencode-ai/core/session/event" import { Provider } from "@/provider/provider" import { generateObject, streamObject, type ModelMessage } from "ai" @@ -77,8 +75,7 @@ export interface Interface { whenToUse: string systemPrompt: string }, - Provider.DefaultModelError, - EventV2Bridge.Service + Provider.DefaultModelError > } @@ -374,7 +371,6 @@ const layer = Layer.effect( }) { const cfg = yield* config.get() const model = input.model ?? (yield* provider.defaultModel()) - const events = yield* EventV2Bridge.Service const resolved = yield* provider.getModel(model.providerID, model.modelID) const language = yield* provider.getLanguage(resolved) const tracer = cfg.experimental?.openTelemetry @@ -383,15 +379,6 @@ const layer = Layer.effect( let system = [PROMPT_GENERATE] yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }) - const decidedSystem = yield* events.waterfall(SessionEvent.Agent.PreSystem, [ - (_e, next) => - Effect.gen(function* () { - const downstream = yield* next() - const downstreamSystem = (downstream as { system?: typeof system } | undefined)?.system - return { system: downstreamSystem ?? system } - }), - ]) - system = (decidedSystem as { system: typeof system }).system const existing = yield* InstanceState.useEffect(state, (s) => s.list()) // TODO: clean this up so provider specific logic doesnt bleed over @@ -460,7 +447,7 @@ const locationServiceMapNode = LayerNode.make({ export const node = LayerNode.make({ service: Service, layer: layer, - deps: [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node, locationServiceMapNode, EventV2Bridge.node], + deps: [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node, locationServiceMapNode], }) export * as Agent from "./agent" diff --git a/packages/opencode/src/operation-hook-bridge.ts b/packages/opencode/src/operation-hook-bridge.ts new file mode 100644 index 000000000000..8879d71bd746 --- /dev/null +++ b/packages/opencode/src/operation-hook-bridge.ts @@ -0,0 +1,65 @@ +export * as OperationHookBridge from "./operation-hook-bridge" + +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { OperationHook } from "@opencode-ai/core/operation-hook" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Context, Effect, Layer } from "effect" +import { InstanceState } from "./effect/instance-state" + +export interface Interface { + readonly waterfall: OperationHook.Interface["waterfall"] + readonly serial: OperationHook.Interface["serial"] + readonly parallel: OperationHook.Interface["parallel"] +} + +export class Service extends Context.Service()("@opencode/OperationHookBridge") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + + const route = (effect: Effect.Effect) => + Effect.gen(function* () { + const context = yield* InstanceState.context + const workspaceID = yield* InstanceState.workspaceID + return yield* effect.pipe( + Effect.provide( + locations.get( + Location.Ref.make({ + directory: AbsolutePath.make(context.directory), + ...(workspaceID ? { workspaceID } : {}), + }), + ), + ), + Effect.orDie, + ) + }) + + return Service.of({ + waterfall: (definition, data) => + route(OperationHook.Service.use((hooks) => hooks.waterfall(definition, data))), + serial: (definition, data) => route(OperationHook.Service.use((hooks) => hooks.serial(definition, data))), + parallel: (definition, data) => route(OperationHook.Service.use((hooks) => hooks.parallel(definition, data))), + }) + }), +) + +export const passthroughLayer = Layer.succeed( + Service, + Service.of({ + waterfall: (_definition, data) => Effect.succeed(data), + serial: () => Effect.void, + parallel: () => Effect.void, + }), +) + +const locationServiceMapNode = LayerNode.make({ + service: LocationServiceMap.Service, + layer: locationServiceMapLayer, + deps: [], +}) + +export const node = LayerNode.make({ service: Service, layer, deps: [locationServiceMapNode] }) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 45df5cb612ec..af0672502ca5 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -2,11 +2,13 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission" import { InstanceState } from "@/effect/instance-state" import { Wildcard } from "@opencode-ai/core/util/wildcard" -import { Deferred, Effect, Layer, Context } from "effect" +import { DateTime, Deferred, Effect, Layer, Context } from "effect" import os from "os" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionEvent } from "@opencode-ai/core/session/event" +import { OperationHook } from "@opencode-ai/core/operation-hook" +import { OperationHookBridge } from "@/operation-hook-bridge" export const Event = PermissionV1.Event @@ -44,6 +46,7 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service + const hooks = yield* OperationHookBridge.Service const state = yield* InstanceState.make( Effect.fn("Permission.state")(function* (ctx) { void ctx @@ -99,7 +102,9 @@ const layer = Layer.effect( const deferred = yield* Deferred.make() pending.set(id, { info, deferred }) yield* events.publish(Event.Asked, info) - yield* events.publish(SessionEvent.Permission.Requested, info as never).pipe(Effect.ignore) + const requested = { timestamp: yield* DateTime.now, ...info } + yield* hooks.parallel(OperationHook.Operation.Permission.Requested, requested) + yield* events.publish(SessionEvent.Permission.Requested, requested).pipe(Effect.ignore) return yield* Effect.ensuring( Deferred.await(deferred), Effect.sync(() => { @@ -220,6 +225,10 @@ export function visibleTools(tools: Record, ruleset: PermissionV1. return Object.fromEntries(Object.entries(tools).filter(([name]) => !hidden.has(name))) } -export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [EventV2Bridge.node, OperationHookBridge.node], +}) export * as Permission from "." diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 124c9fa5664c..bc92982ea9b4 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -13,7 +13,7 @@ import { Plugin } from "@/plugin" import { Config } from "@/config/config" import { NotFoundError } from "@/storage/storage" -import { Effect, Layer, Context } from "effect" +import { DateTime, Effect, Layer, Context } from "effect" import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { serviceUse } from "@opencode-ai/core/effect/service-use" @@ -23,6 +23,8 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { buildPrompt } from "@opencode-ai/core/session/compaction" import { SessionCompactionEvent } from "@opencode-ai/schema/session-compaction-event" +import { OperationHook } from "@opencode-ai/core/operation-hook" +import { OperationHookBridge } from "@/operation-hook-bridge" export const Event = SessionCompactionEvent @@ -199,6 +201,7 @@ const layer = Layer.effect( const processors = yield* SessionProcessor.Service const provider = yield* Provider.Service const events = yield* EventV2Bridge.Service + const hooks = yield* OperationHookBridge.Service const flags = yield* RuntimeFlags.Service const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: { @@ -376,19 +379,12 @@ const layer = Layer.effect( { sessionID: input.sessionID }, { context: [], prompt: undefined }, ) - const compactingV2 = yield* events.waterfall(SessionEvent.Compaction.PreCompact, [ - (_e, next) => - Effect.gen(function* () { - const downstream = yield* next() - const downstreamCtx = (downstream as { context?: unknown[] } | undefined)?.context - const downstreamPrompt = (downstream as { prompt?: string } | undefined)?.prompt - return { - context: downstreamCtx ?? compacting.context, - prompt: downstreamPrompt ?? compacting.prompt, - } - }), - ]) - const compactingFinal = compactingV2 as { context: unknown[]; prompt: string | undefined } + const compactingFinal = yield* hooks.waterfall(OperationHook.Operation.Compaction.PreCompact, { + timestamp: yield* DateTime.now, + sessionID: input.sessionID, + context: compacting.context, + prompt: compacting.prompt, + }) // Mutate the V1 trigger result with the V2-decided context/prompt so the // downstream code keeps working with a single source of truth. ;(compacting as { context: unknown[] }).context = compactingFinal.context @@ -619,6 +615,7 @@ export const node = LayerNode.make({ SessionProcessor.node, Provider.node, EventV2Bridge.node, + OperationHookBridge.node, RuntimeFlags.node, ], }) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a99f8acff20c..d4226d055fa3 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -29,6 +29,7 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer" import { LLMAISDK } from "./llm/ai-sdk" import { LLMNativeRuntime } from "./llm/native-runtime" import { LLMRequestPrep } from "./llm/request" +import { OperationHookBridge } from "@/operation-hook-bridge" export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX @@ -70,6 +71,7 @@ const live: Layer.Layer< | EventV2Bridge.Service | LLMClientService | RuntimeFlags.Service + | OperationHookBridge.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -81,6 +83,7 @@ const live: Layer.Layer< const events = yield* EventV2Bridge.Service const llmClient = yield* LLMClient.Service const flags = yield* RuntimeFlags.Service + const hooks = yield* OperationHookBridge.Service const run = Effect.fn("LLM.run")(function* (input: StreamRequest) { yield* Effect.logInfo("stream", { @@ -110,6 +113,7 @@ const live: Layer.Layer< plugin, flags, isWorkflow, + hooks, }) // Wire up toolExecutor for DWS workflow models so that tool calls @@ -398,6 +402,7 @@ export const node = LayerNode.make({ EventV2Bridge.node, llmClient, RuntimeFlags.node, + OperationHookBridge.node, ], }) diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 4f93411107df..8d82510bf8cd 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -10,10 +10,12 @@ import type { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { SystemPrompt } from "../system" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { Effect, Record } from "effect" +import { DateTime, Effect, Record } from "effect" import { jsonSchema, tool as aiTool, type ModelMessage, type Tool } from "ai" import type { Plugin } from "@/plugin" import { mergeDeep } from "remeda" +import { OperationHook } from "@opencode-ai/core/operation-hook" +import type { OperationHookBridge } from "@/operation-hook-bridge" const USER_AGENT = `opencode/${InstallationVersion}` @@ -33,6 +35,7 @@ type PrepareInput = { readonly plugin: Plugin.Interface readonly flags: RuntimeFlags.Info readonly isWorkflow: boolean + readonly hooks: OperationHookBridge.Interface } export type Prepared = { @@ -71,6 +74,14 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre { sessionID: input.sessionID, model: input.model }, { system }, ) + const decidedSystem = yield* input.hooks.waterfall(OperationHook.Operation.Agent.PreSystem, { + timestamp: yield* DateTime.now, + sessionID: input.sessionID, + agent: input.agent.name, + messageID: input.user.id, + system, + }) + system.splice(0, system.length, ...decidedSystem.system.filter((item): item is string => typeof item === "string")) if (system.length > 2 && system[0] === header) { const rest = system.slice(1) system.length = 0 diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 3d1df77c0e9c..9fad7dc6e9fd 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -2,7 +2,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Image } from "@/image/image" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect" +import { Cause, DateTime, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Config } from "@/config/config" @@ -26,6 +26,8 @@ import { EventV2Bridge } from "@/event-v2-bridge" import { SessionEvent } from "@opencode-ai/core/session/event" import { Database } from "@opencode-ai/core/database/database" import { Usage, type LLMEvent } from "@opencode-ai/llm" +import { OperationHook } from "@opencode-ai/core/operation-hook" +import { OperationHookBridge } from "@/operation-hook-bridge" const DOOM_LOOP_THRESHOLD = 3 export type Result = "compact" | "stop" | "continue" @@ -94,6 +96,7 @@ const layer = Layer.effect( const status = yield* SessionStatus.Service const image = yield* Image.Service const events = yield* EventV2Bridge.Service + const hooks = yield* OperationHookBridge.Service const database = yield* Database.Service const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { @@ -524,15 +527,14 @@ const layer = Layer.effect( }, { text: ctx.currentText.text }, ) - const textCompleteV2 = yield* events.waterfall(SessionEvent.Text.Complete, [ - (_e, next) => - Effect.gen(function* () { - const downstream = yield* next() - const downstreamText = (downstream as { text?: string } | undefined)?.text - return { text: downstreamText ?? textCompleteV1.text } - }), - ]) - ctx.currentText.text = (textCompleteV2 as { text: string }).text + const textCompleteV2 = yield* hooks.waterfall(OperationHook.Operation.Text.Complete, { + timestamp: yield* DateTime.now, + sessionID: ctx.sessionID, + messageID: ctx.assistantMessage.id, + partID: ctx.currentText.id, + text: textCompleteV1.text, + }) + ctx.currentText.text = textCompleteV2.text { const end = Date.now() ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } @@ -722,6 +724,7 @@ export const node = LayerNode.make({ SessionStatus.node, Image.node, EventV2Bridge.node, + OperationHookBridge.node, Database.node, ], }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5043eb479525..cdd0c677ab29 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -57,6 +57,8 @@ import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionReminders } from "./reminders" import { SessionTools } from "./tools" import { LLMEvent } from "@opencode-ai/llm" +import { OperationHook } from "@opencode-ai/core/operation-hook" +import { OperationHookBridge } from "@/operation-hook-bridge" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -139,6 +141,7 @@ const layer = Layer.effect( const sys = yield* SystemPrompt.Service const llm = yield* LLM.Service const events = yield* EventV2Bridge.Service + const hooks = yield* OperationHookBridge.Service const flags = yield* RuntimeFlags.Service const database = yield* Database.Service const { db } = database @@ -1087,9 +1090,9 @@ const layer = Layer.effect( const session = yield* sessions.get(sessionID).pipe(Effect.orDie) // Turn lifecycle: fire `Turn.Started` once per turn (before any step). - yield* events - .publish(SessionEvent.Turn.Started, { sessionID, timestamp: yield* DateTime.now }) - .pipe(Effect.ignore) + const turnStarted = { sessionID, timestamp: yield* DateTime.now } + yield* hooks.parallel(OperationHook.Operation.Turn.Started, turnStarted) + yield* events.publish(SessionEvent.Turn.Started, turnStarted).pipe(Effect.ignore) while (true) { yield* status.set(sessionID, { type: "busy" }) @@ -1230,6 +1233,19 @@ const layer = Layer.effect( const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false const promptOps = yield* ops() + if (step === 1) + yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope)) + + yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) + const decided = yield* hooks.waterfall(OperationHook.Operation.Agent.PreStep, { + timestamp: yield* DateTime.now, + sessionID, + agent: agent.name, + messageID: handle.message.id, + messages: msgs, + }) + msgs = decided.messages as typeof msgs + const tools = yield* SessionTools.resolve({ agent, session, @@ -1238,6 +1254,17 @@ const layer = Layer.effect( bypassAgentCheck, messages: msgs, promptOps, + publishEvent: events.publish, + ...(lastUser.format?.type === "json_schema" + ? { + structuredOutputTool: createStructuredOutputTool({ + schema: lastUser.format.schema, + onSuccess(output) { + structured = output + }, + }), + } + : {}), }).pipe( Effect.provideService(Plugin.Service, plugin), Effect.provideService(Permission.Service, permission), @@ -1245,35 +1272,9 @@ const layer = Layer.effect( Effect.provideService(MCP.Service, mcp), Effect.provideService(Truncate.Service, truncate), Effect.provideService(RuntimeFlags.Service, flags), + Effect.provideService(OperationHookBridge.Service, hooks), ) - if (lastUser.format?.type === "json_schema") { - tools["StructuredOutput"] = createStructuredOutputTool({ - schema: lastUser.format.schema, - onSuccess(output) { - structured = output - }, - }) - } - - if (step === 1) - yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope)) - - const decided = yield* events.waterfall(SessionEvent.Agent.PreStep, [ - (_e, next) => - Effect.gen(function* () { - yield* plugin.trigger( - "experimental.chat.messages.transform", - {}, - { messages: msgs }, - ) - const downstream = yield* next() - const downstreamMsgs = (downstream as { messages?: typeof msgs } | undefined)?.messages - return { messages: downstreamMsgs ?? msgs } - }), - ]) - msgs = (decided as { messages: typeof msgs }).messages - const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([ sys.skills(agent), sys.environment(model), @@ -1369,13 +1370,13 @@ const layer = Layer.effect( runLoop(input.sessionID).pipe( Effect.onExit((exit) => Effect.gen(function* () { - yield* events - .publish(SessionEvent.Turn.Ended, { - sessionID: input.sessionID, - timestamp: yield* DateTime.now, - finished: Exit.isSuccess(exit), - }) - .pipe(Effect.ignore) + const turnEnded = { + sessionID: input.sessionID, + timestamp: yield* DateTime.now, + finished: Exit.isSuccess(exit), + } + yield* hooks.parallel(OperationHook.Operation.Turn.Ended, turnEnded) + yield* events.publish(SessionEvent.Turn.Ended, turnEnded).pipe(Effect.ignore) }), ), ), @@ -1498,15 +1499,14 @@ const layer = Layer.effect( { command: input.command, sessionID: input.sessionID, arguments: input.arguments }, { parts }, ) - const decidedCommand = yield* events.waterfall(SessionEvent.Command.PreExecute, [ - (_e, next) => - Effect.gen(function* () { - const downstream = yield* next() - const downstreamParts = (downstream as { parts?: typeof parts } | undefined)?.parts - return { parts: downstreamParts ?? parts } - }), - ]) - parts = (decidedCommand as { parts: typeof parts }).parts + const decidedCommand = yield* hooks.waterfall(OperationHook.Operation.Command.PreExecute, { + timestamp: yield* DateTime.now, + sessionID: input.sessionID, + command: input.command, + arguments: input.arguments, + parts, + }) + parts = decidedCommand.parts as typeof parts const result = yield* prompt({ sessionID: input.sessionID, @@ -1668,6 +1668,7 @@ export const node = LayerNode.make({ SystemPrompt.node, LLM.node, EventV2Bridge.node, + OperationHookBridge.node, RuntimeFlags.node, Database.node, ], diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index c3cf7770d50d..2601e497c519 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -15,7 +15,7 @@ import { Truncate } from "@/tool/truncate" import { Plugin } from "@/plugin" import type { TaskPromptOps } from "@/tool/task" import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai" -import { Effect } from "effect" +import { Cause, DateTime, Effect, Exit } from "effect" import { MessageV2 } from "./message-v2" import { Session } from "./session" import { SessionProcessor } from "./processor" @@ -25,6 +25,9 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { isRecord } from "@/util/record" import { RuntimeFlags } from "@/effect/runtime-flags" +import { OperationHook } from "@opencode-ai/core/operation-hook" +import { OperationHookBridge } from "@/operation-hook-bridge" +import { SessionMessage } from "@opencode-ai/schema/session-message" const MCP_RESOURCE_TOOLS = { list: "list_mcp_resources", @@ -48,6 +51,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { bypassAgentCheck: boolean messages: SessionV1.WithParts[] promptOps: TaskPromptOps + publishEvent: EventV2.Interface["publish"] + structuredOutputTool?: AITool }) { const tools: Record = {} const run = yield* EffectBridge.make() @@ -57,6 +62,62 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const mcp = yield* MCP.Service const truncate = yield* Truncate.Service const flags = yield* RuntimeFlags.Service + const hooks = yield* OperationHookBridge.Service + + const withOperationHooks = (toolID: string, item: AITool): AITool => { + const execute = item.execute + if (!execute) return item + return { + ...item, + execute(args, options) { + return run.promise( + Effect.gen(function* () { + const hookArgs = toRecord(args) + yield* plugin.trigger( + "tool.execute.before", + { tool: toolID, sessionID: input.session.id, callID: options.toolCallId }, + { args: hookArgs }, + ) + const decided = yield* hooks.waterfall(OperationHook.Operation.Tool.PreExecute, { + timestamp: yield* DateTime.now, + sessionID: input.session.id, + assistantMessageID: SessionMessage.ID.make(input.processor.message.id), + callID: options.toolCallId ?? "", + tool: toolID, + args: hookArgs, + }) + const publishPost = (output: unknown, failed: boolean) => + Effect.gen(function* () { + const payload = { + timestamp: yield* DateTime.now, + tool: toolID, + sessionID: input.session.id, + assistantMessageID: SessionMessage.ID.make(input.processor.message.id), + callID: options.toolCallId ?? "", + args: decided.args, + output, + failed, + } + yield* hooks.parallel(OperationHook.Operation.Tool.PostExecute, payload) + yield* input.publishEvent(SessionEvent.Tool.PostExecute, payload).pipe(Effect.ignore) + }) + const executed = yield* Effect.promise(() => Promise.resolve(execute(decided.args, options))).pipe( + Effect.exit, + ) + if (Exit.isFailure(executed)) { + yield* publishPost({ error: String(Cause.squash(executed.cause)) }, true).pipe(Effect.ignoreCause) + return yield* Effect.failCause(executed.cause) + } + yield* publishPost(executed.value, false) + return executed.value + }), + ) + }, + } + } + + const withAllOperationHooks = () => + Object.fromEntries(Object.entries(tools).map(([toolID, item]) => [toolID, withOperationHooks(toolID, item)])) const context = (args: Record, options: ToolExecutionOptions): Tool.Context => ({ sessionID: input.session.id, @@ -105,22 +166,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { return run.promise( Effect.gen(function* () { const ctx = context(args, options) - const events = yield* EventV2.Service - const decided = yield* events.waterfall(SessionEvent.Tool.PreExecute, [ - (_e, next) => - Effect.gen(function* () { - yield* plugin.trigger( - "tool.execute.before", - { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, - { args }, - ) - const downstream = yield* next() - const downstreamArgs = (downstream as { args?: typeof args } | undefined)?.args - return { args: downstreamArgs ?? args } - }), - ]) - const finalArgs = (decided as { args: typeof args }).args - const result = yield* item.execute(finalArgs, ctx) + const result = yield* item.execute(args, ctx) const output = { ...result, attachments: result.attachments?.map((attachment) => ({ @@ -135,19 +181,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, output, ) - // V2 shim: live observation event. Plugins subscribe via `events.subscribe` - // or `events.subscribeAll`; no veto (the call already happened). - const postPayload = { - tool: item.id, - sessionID: ctx.sessionID, - callID: ctx.callID ?? "", - args, - output, - failed: false, - } - yield* events - .publish(SessionEvent.Tool.PostExecute, postPayload as never) - .pipe(Effect.ignore) if (options.abortSignal?.aborted) { yield* input.processor.completeToolCall(options.toolCallId, output) } @@ -197,11 +230,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const permissionPatterns = parsed.server ? [`mcp:${parsed.server}:*`] : resourceServers.map((server) => `mcp:${server}:*`) - yield* plugin.trigger( - "tool.execute.before", - { tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId }, - { args }, - ) yield* ctx.ask({ permission: "read", metadata: parsed.server ? { server: parsed.server } : {}, @@ -280,11 +308,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const permissionPatterns = parsed.server ? [`mcp:${parsed.server}:*`] : resourceServers.map((server) => `mcp:${server}:*`) - yield* plugin.trigger( - "tool.execute.before", - { tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId }, - { args }, - ) yield* ctx.ask({ permission: "read", metadata: parsed.server ? { server: parsed.server } : {}, @@ -360,11 +383,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { if (!client.getServerCapabilities()?.resources) { throw new Error(`MCP server "${parsed.server}" does not support resources`) } - yield* plugin.trigger( - "tool.execute.before", - { tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId }, - { args }, - ) yield* ctx.ask({ permission: "read", metadata: { server: parsed.server, uri: parsed.uri }, @@ -410,7 +428,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }) } - if (flags.experimentalCodeMode) return tools + if (input.structuredOutputTool) tools.StructuredOutput = input.structuredOutputTool + if (flags.experimentalCodeMode) return withAllOperationHooks() for (const [key, entry] of Object.entries(yield* mcp.tools())) { const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout) @@ -424,11 +443,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { run.promise( Effect.gen(function* () { const ctx = context(args, opts) - yield* plugin.trigger( - "tool.execute.before", - { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, - { args }, - ) const result: Awaited>> = yield* Effect.gen(function* () { yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) return yield* Effect.promise(() => execute(args, opts)) @@ -514,7 +528,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { tools[key] = item } - return tools + return withAllOperationHooks() }) function toRecord(value: unknown) { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 701658987402..3bacf9b720ef 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -578,6 +578,9 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { } as any, flags: { outputTokenMax: 32_000, client: "test" } as any, isWorkflow: false, + hooks: { + waterfall: (_definition: unknown, data: unknown) => Effect.succeed(data), + } as any, }), ) expect(result.params.options.reasoningEffort).toBe("high") diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 052477d0a2e7..530a5da937eb 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -2,6 +2,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2Bridge } from "@/event-v2-bridge" +import { OperationHookBridge } from "@/operation-hook-bridge" import { expect } from "bun:test" import { tool } from "ai" import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect" @@ -178,6 +179,7 @@ const root = LayerNode.group([ const replacements = [ [SessionSummary.node, summary], [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], + [OperationHookBridge.node, OperationHookBridge.passthroughLayer], ] as const const env = LayerNode.compile( LayerNode.group([root, LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] })]), diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index d81d8dd0aaa9..a7f5f437e651 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2,6 +2,7 @@ import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { SessionProjector } from "@opencode-ai/core/session/projector" import { eq } from "drizzle-orm" import { EventV2Bridge } from "@/event-v2-bridge" @@ -58,6 +59,10 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { Location } from "@opencode-ai/core/location" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { define, Operation } from "@opencode-ai/plugin/v2/effect" const summary = Layer.succeed( SessionSummary.Service, @@ -207,6 +212,7 @@ const promptRoot = LayerNode.group([ SystemPrompt.node, CrossSpawnSpawner.node, RuntimeFlags.node, + LocationServiceMap.node, ]) function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; processor?: "blocking" }) { @@ -217,9 +223,9 @@ function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; proces [RuntimeFlags.node, runtimeFlags], ] as const if (input?.processor === "blocking") { - return LayerNode.compile(promptRoot, [...replacements, [SessionProcessor.node, blockingProcessor]]) + return AppNodeBuilder.build(promptRoot, [...replacements, [SessionProcessor.node, blockingProcessor]]) } - return LayerNode.compile(promptRoot, replacements) + return AppNodeBuilder.build(promptRoot, replacements) } function makeHttp(input?: { mcpInstructions?: MCP.ServerInstructions[]; processor?: "blocking" }) { @@ -231,9 +237,9 @@ function makeHttp(input?: { mcpInstructions?: MCP.ServerInstructions[]; processo [RuntimeFlags.node, runtimeFlags], ] as const if (input?.processor === "blocking") { - return LayerNode.compile(root, [...replacements, [SessionProcessor.node, blockingProcessor]]) + return AppNodeBuilder.build(root, [...replacements, [SessionProcessor.node, blockingProcessor]]) } - return LayerNode.compile(root, replacements) + return AppNodeBuilder.build(root, replacements) } function makeHttpNoLLMServer(input?: { mcpInstructions?: MCP.ServerInstructions[]; processor?: "blocking" }) { @@ -616,6 +622,68 @@ it.instance("loop emits successful turn lifecycle events", () => }), ) +it.instance("runs Location-scoped V2 operation hooks", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const test = yield* TestInstance + const locations = yield* LocationServiceMap.Service + let started = 0 + const post = new Array<{ failed: boolean; command: unknown }>() + const output = path.join(test.directory, "operation-hook.txt") + yield* Effect.gen(function* () { + const plugins = yield* PluginV2.Service + yield* plugins.add( + PluginV2.ID.make("prompt-operation-hooks"), + define({ + id: "prompt-operation-hooks", + effect: (ctx) => + Effect.gen(function* () { + yield* ctx.hook.parallel(Operation.Turn.Started, () => { + started += 1 + }) + yield* ctx.hook.waterfall(Operation.Agent.PreSystem, (event, next) => + next({ ...event.data, system: [...event.data.system, "V2 operation hook marker"] }), + ) + yield* ctx.hook.waterfall(Operation.Tool.PreExecute, (event, next) => + next({ ...event.data, args: { ...event.data.args, command: `printf hook > ${JSON.stringify(output)}` } }), + ) + yield* ctx.hook.parallel(Operation.Tool.PostExecute, (event) => { + post.push({ failed: event.data.failed, command: event.data.args.command }) + }) + }), + }).effect, + ) + }).pipe( + Effect.provide( + locations.get(Location.Ref.make({ directory: AbsolutePath.make(test.directory) })), + ), + Effect.orDie, + ) + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("hello"), "bash", { command: "printf wrong" }) + yield* llm.text("world") + + yield* prompt.loop({ sessionID: chat.id }) + + expect(started).toBe(1) + expect(JSON.stringify((yield* llm.hits)[0]?.body)).toContain("V2 operation hook marker") + expect(yield* Effect.promise(() => Bun.file(output).text())).toBe("hook") + expect(post).toEqual([{ failed: false, command: `printf hook > ${JSON.stringify(output)}` }]) + }), +) + it.instance("loop emits interrupted turn lifecycle events", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 1265237840f3..8317bfbde7f3 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -13,6 +13,7 @@ */ import { expect } from "bun:test" import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import fs from "fs/promises" import path from "path" @@ -87,7 +88,7 @@ const root = LayerNode.group([ LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }), ]) const it = testEffect( - LayerNode.compile(root, [ + AppNodeBuilder.build(root, [ [MCP.node, mcp], [LSP.node, lsp], [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], @@ -154,6 +155,16 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => const result = yield* prompt.loop({ sessionID: session.id }) expect(result.info.role).toBe("assistant") + // Verify the tool call completed (in the first assistant message) + const allMsgs = yield* MessageV2.filterCompactedEffect(session.id) + const user = allMsgs.find( + (msg): msg is SessionV1.WithParts & { info: SessionV1.User } => msg.info.role === "user", + ) + const tool = allMsgs + .flatMap((m) => m.parts) + .find((p): p is SessionV1.ToolPart => p.type === "tool" && p.tool === "bash") + expect(tool?.state).toMatchObject({ status: "completed" }) + // Verify the file was created const filePath = path.join(dir, "race-test.txt") const fileExists = yield* Effect.promise(() => @@ -164,15 +175,6 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => ) expect(fileExists).toBe(true) - // Verify the tool call completed (in the first assistant message) - const allMsgs = yield* MessageV2.filterCompactedEffect(session.id) - const user = allMsgs.find( - (msg): msg is SessionV1.WithParts & { info: SessionV1.User } => msg.info.role === "user", - ) - const tool = allMsgs - .flatMap((m) => m.parts) - .find((p): p is SessionV1.ToolPart => p.type === "tool" && p.tool === "bash") - expect(tool?.state.status).toBe("completed") if (!user) throw new Error("Expected user message") // Poll for the turn diff — summarize() is fire-and-forget. diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 29b9c93ed992..be25ba510300 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -14,8 +14,10 @@ "./tui": "./src/tui.ts", "./v2/effect": "./src/v2/effect/index.ts", "./v2/effect/integration": "./src/v2/effect/integration.ts", + "./v2/effect/operation-hook": "./src/v2/effect/operation-hook.ts", "./v2/effect/plugin": "./src/v2/effect/plugin.ts", - "./v2/promise": "./src/v2/promise/index.ts" + "./v2/promise": "./src/v2/promise/index.ts", + "./v2/promise/operation-hook": "./src/v2/promise/operation-hook.ts" }, "files": [ "dist" diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts index 3042d8f10fa2..7823b3ba5b65 100644 --- a/packages/plugin/src/v2/effect/context.ts +++ b/packages/plugin/src/v2/effect/context.ts @@ -9,6 +9,7 @@ import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" import type { SkillHooks } from "./skill.js" import type { Reload } from "./registration.js" +import type { Hooks as OperationHooks } from "./operation-hook.js" export interface PluginContext { readonly options: PluginOptions @@ -18,6 +19,7 @@ export interface PluginContext { readonly catalog: CatalogHooks & Reload readonly command: CommandHooks & Reload readonly integration: IntegrationHooks & Reload + readonly hook: OperationHooks readonly plugin: PluginDomain readonly reference: ReferenceHooks & Reload readonly skill: SkillHooks & Reload diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index f13614a54da5..915a74e4d8b9 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -1,3 +1,14 @@ export type { PluginContext } from "./context.js" export { define } from "./plugin.js" export type { Plugin } from "./plugin.js" +export { Definition as OperationDefinition, Operation } from "./operation-hook.js" +export type { + Data as OperationData, + Hooks as OperationHooks, + Location as OperationLocation, + Mode as OperationMode, + Next as OperationNext, + Observer as OperationObserver, + Payload as OperationPayload, + WaterfallHandler as OperationWaterfallHandler, +} from "./operation-hook.js" diff --git a/packages/plugin/src/v2/effect/operation-hook.ts b/packages/plugin/src/v2/effect/operation-hook.ts new file mode 100644 index 000000000000..a7534a413190 --- /dev/null +++ b/packages/plugin/src/v2/effect/operation-hook.ts @@ -0,0 +1,151 @@ +import type { DateTime, Effect, Scope } from "effect" +import type { Registration } from "./registration.js" + +export type Mode = "waterfall" | "serial" | "parallel" + +export class Definition< + Type extends string = string, + Value = unknown, + HookMode extends Mode = Mode, +> { + declare readonly Value: Value + + constructor( + readonly type: Type, + readonly mode: HookMode, + ) {} +} + +export type Data = D["Value"] +export type WaterfallDefinition = Definition +export type SerialDefinition = Definition +export type ParallelDefinition = Definition + +export interface Location { + readonly directory: string + readonly workspaceID?: string +} + +export interface Payload { + readonly id: string + readonly type: D["type"] + readonly location: Location + readonly data: Data +} + +export type Next = (data?: Data) => Effect.Effect> +export type WaterfallHandler = ( + event: Payload, + next: Next, +) => Effect.Effect> | Data +export type Observer = ( + event: Payload, +) => Effect.Effect | void + +export interface Hooks { + readonly waterfall: ( + definition: D, + callback: WaterfallHandler, + ) => Effect.Effect + readonly serial: ( + definition: D, + callback: Observer, + ) => Effect.Effect + readonly parallel: ( + definition: D, + callback: Observer, + ) => Effect.Effect +} + +export interface SessionData { + timestamp: DateTime.Utc + sessionID: string +} + +export interface AgentPreStepData extends SessionData { + agent: string + messageID: string + messages: unknown[] +} + +export interface AgentPreSystemData extends SessionData { + agent: string + messageID: string + system: unknown[] +} + +export interface ToolPreExecuteData extends SessionData { + assistantMessageID: string + callID: string + tool: string + args: Record +} + +export interface ToolPostExecuteData extends ToolPreExecuteData { + output: unknown + failed: boolean +} + +export interface CommandPreExecuteData extends SessionData { + command: string + arguments: string + parts: unknown[] +} + +export interface PermissionRequestedData extends SessionData { + id: string + permission: string + patterns: ReadonlyArray + metadata: Readonly> + always: ReadonlyArray + tool?: { + messageID: string + callID: string + } +} + +export interface CompactionPreCompactData extends SessionData { + context: unknown[] + prompt?: string +} + +export interface TextCompleteData extends SessionData { + messageID: string + partID: string + text: string +} + +export interface TurnEndedData extends SessionData { + finished: boolean +} + +const define = () => + (type: Type, mode: HookMode) => + new Definition(type, mode) + +export const Operation = { + Agent: { + PreStep: define()("session.next.agent.pre_step", "waterfall"), + PreSystem: define()("session.next.agent.pre_system", "waterfall"), + }, + Tool: { + PreExecute: define()("session.next.tool.pre_execute", "waterfall"), + PostExecute: define()("session.next.tool.post_execute", "parallel"), + }, + Command: { + PreExecute: define()("session.next.command.pre_execute", "waterfall"), + }, + Permission: { + Requested: define()("session.next.permission.requested", "parallel"), + }, + Compaction: { + PreCompact: define()("session.next.compaction.pre_compact", "waterfall"), + }, + Text: { + Complete: define()("session.next.text.complete", "waterfall"), + }, + Turn: { + Started: define()("session.next.turn.started", "parallel"), + Ended: define()("session.next.turn.ended", "parallel"), + }, +} as const diff --git a/packages/plugin/src/v2/promise/context.ts b/packages/plugin/src/v2/promise/context.ts index 9089334ee3bb..125a7a0ba1b7 100644 --- a/packages/plugin/src/v2/promise/context.ts +++ b/packages/plugin/src/v2/promise/context.ts @@ -8,6 +8,7 @@ import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" import type { SkillHooks } from "./skill.js" import type { Reload } from "./registration.js" +import type { Hooks as OperationHooks } from "./operation-hook.js" export interface PluginContext { readonly options: PluginOptions @@ -16,6 +17,7 @@ export interface PluginContext { readonly catalog: CatalogHooks & Reload readonly command: CommandHooks & Reload readonly integration: IntegrationHooks & Reload + readonly hook: OperationHooks readonly plugin: PluginDomain readonly reference: ReferenceHooks & Reload readonly skill: SkillHooks & Reload diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index 82879556573b..824720b87791 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -10,3 +10,12 @@ export type { CommandDraft, CommandHooks } from "./command.js" export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { SkillDraft, SkillHooks } from "./skill.js" +export { Definition as OperationDefinition, Operation } from "./operation-hook.js" +export type { + Data as OperationData, + Hooks as OperationHooks, + Next as OperationNext, + Observer as OperationObserver, + Payload as OperationPayload, + WaterfallHandler as OperationWaterfallHandler, +} from "./operation-hook.js" diff --git a/packages/plugin/src/v2/promise/operation-hook.ts b/packages/plugin/src/v2/promise/operation-hook.ts new file mode 100644 index 000000000000..f635684ecb02 --- /dev/null +++ b/packages/plugin/src/v2/promise/operation-hook.ts @@ -0,0 +1,29 @@ +import { + Definition, + Operation, + type Data, + type ParallelDefinition, + type Payload, + type SerialDefinition, + type WaterfallDefinition, +} from "../effect/operation-hook.js" +import type { Registration } from "./registration.js" + +export { Definition, Operation } +export type { Data, Payload } + +export type Next = (data?: Data) => Promise> +export type WaterfallHandler = ( + event: Payload, + next: Next, +) => Promise> | Data +export type Observer = (event: Payload) => Promise | void + +export interface Hooks { + readonly waterfall: ( + definition: D, + callback: WaterfallHandler, + ) => Promise + readonly serial: (definition: D, callback: Observer) => Promise + readonly parallel: (definition: D, callback: Observer) => Promise +} diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index c6aaa4046d5f..b080d06be7fd 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -12,7 +12,7 @@ import { SessionID } from "./session-id" import { Location } from "./location" import { SessionMessage } from "./session-message" import { Revert } from "./revert" -import { PermissionV1 } from "./v1/permission" +import { PermissionV1 } from "./permission-v1" export { FileAttachment } @@ -60,11 +60,10 @@ export namespace Agent { } /** - * Live waterfall fired before each step's model request. Listeners return - * `{ messages }` to rewrite the history that reaches the LLM, or throw to - * abort the turn. This is the V2 replacement for the legacy V1 - * `experimental.chat.messages.transform` hook and gives plugins proper - * `next()`-based mutation semantics with short-circuit. + * Live waterfall fired before each step's model request. V2 plugins return + * transformed data to rewrite the history that reaches the LLM, or throw to + * abort the turn. Calling `next()` continues the Location-scoped waterfall; + * skipping it short-circuits. */ export const PreStep = Event.define({ type: "session.next.agent.pre_step", @@ -77,10 +76,9 @@ export namespace Agent { /** * Live waterfall fired when assembling the system prompt for a model call. - * Listeners return `{ system }` to rewrite the system prompt that reaches - * the LLM, or throw to abort. This is the V2 replacement for the legacy V1 - * `experimental.chat.system.transform` hook and gives plugins proper - * `next()`-based mutation semantics with short-circuit. + * V2 plugins return transformed data to rewrite the system prompt that + * reaches the LLM, or throw to abort. Calling `next()` continues the + * Location-scoped waterfall; skipping it short-circuits. */ export const PreSystem = Event.define({ type: "session.next.agent.pre_system", @@ -298,10 +296,8 @@ export namespace Text { /** * Live waterfall fired when a text part's stream completes, before the - * final value is persisted. Listeners return `{ text }` to transform the - * final text or throw to veto. This is the V2 replacement for the legacy - * V1 `experimental.text.complete` hook and gives plugins proper - * `next()`-based mutation semantics. + * final value is persisted. V2 plugins register through + * `ctx.hook.waterfall` to transform the final text or throw to veto. */ export const Complete = Event.define({ type: "session.next.text.complete", @@ -363,9 +359,8 @@ export namespace Tool { /** * Live waterfall fired immediately before a tool's `execute` runs. - * Listeners return `{ args }` to transform the call input, or throw to veto. - * This is the V2 replacement for the legacy V1 `tool.execute.before` hook - * and gives plugins proper deny/transform semantics with `next()`. + * V2 plugins register through `ctx.hook.waterfall` to transform the call + * input or throw to veto before execution. */ export const PreExecute = Event.define({ type: "session.next.tool.pre_execute", @@ -381,7 +376,7 @@ export namespace Tool { * Live event fired after a tool's `execute` completes (success or failure). * Listeners observe the call input and the output; no veto (the call already * happened). This is the V2 replacement for the legacy V1 `tool.execute.after` - * hook and gives plugins proper parallel dispatch with result collection. + * hook and dispatches Location-scoped observers in parallel. */ export const PostExecute = Event.define({ type: "session.next.tool.post_execute", @@ -506,9 +501,8 @@ export namespace Permission { /** * Live event fired when a permission request is queued for the user. Plugins * observe (no veto — the ask already happened). This is the V2 surface for - * the legacy V1 `permission.ask` hook; subscribe via `events.subscribe` or - * `events.subscribeAll` to attach audit/telemetry without registering a V1 - * plugin. + * the legacy V1 `permission.ask` hook; V2 plugins register a parallel + * observer through `ctx.hook.parallel`. */ export const Requested = Event.define({ type: "session.next.permission.requested", @@ -527,10 +521,9 @@ export namespace Command { } /** - * Live waterfall fired before a slash command runs. Listeners return - * `{ parts }` to rewrite the prompt payload, or throw to veto. This is - * the V2 replacement for the legacy V1 `command.execute.before` hook - * and gives plugins proper deny/transform semantics with `next()`. + * Live waterfall fired before a slash command runs. V2 plugins return + * transformed data to rewrite the prompt payload, or throw to veto through + * `ctx.hook.waterfall`. */ export const PreExecute = Event.define({ type: "session.next.command.pre_execute", @@ -569,8 +562,8 @@ export namespace Compaction { /** * Live waterfall fired right before a compaction runs. Listeners return * `{ context, prompt }` to inject context or replace the compaction prompt. - * This is the V2 replacement for the legacy V1 `experimental.session.compacting` - * hook and gives plugins proper `next()`-based mutation semantics. + * V2 plugins register through `ctx.hook.waterfall` and use `next()` to + * compose transformations in registration order. */ export const PreCompact = Event.define({ type: "session.next.compaction.pre_compact", diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 5694afdd30df..7de001b5861f 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -9,8 +9,8 @@ import { WorkspaceEvent } from "../src/workspace-event" describe("public event manifest", () => { test("owns the complete public event surface", () => { - expect(EventManifest.ServerDefinitions.length).toBe(55) - expect(EventManifest.Definitions.length).toBe(85) + expect(EventManifest.ServerDefinitions.length).toBe(58) + expect(EventManifest.Definitions.length).toBe(88) expect(SessionV1.Event.Definitions).toEqual([ SessionV1.Event.Created, SessionV1.Event.Updated, @@ -23,8 +23,8 @@ describe("public event manifest", () => { SessionV1.Event.Diff, SessionV1.Event.Error, ]) - expect(EventManifest.Latest.size).toBe(85) - expect(EventManifest.Durable.size).toBe(32) + expect(EventManifest.Latest.size).toBe(88) + expect(EventManifest.Durable.size).toBe(35) }) test("uses canonical definitions for current public events", () => { @@ -42,7 +42,7 @@ describe("public event manifest", () => { expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) - expect(EventManifest.Definitions.slice(40, 43)).toEqual([ + expect(EventManifest.Definitions.slice(43, 46)).toEqual([ SessionV1.Event.PartDelta, SessionV1.Event.Diff, SessionV1.Event.Error,