From 23f0ee53cf04a398423bf88911668978548eef32 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 27 Aug 2026 18:58:00 +0530 Subject: [PATCH] feat(plugin): intercept user prompts before admission (#45550) --- packages/core/src/location-services.ts | 2 + packages/core/src/session.ts | 99 ++--- packages/core/src/session/inbox.ts | 26 +- packages/core/test/fixture/prompt-location.ts | 18 + packages/core/test/plugin-session.types.ts | 32 ++ packages/core/test/session-create.test.ts | 7 +- .../core/test/session-prompt-hooks.test.ts | 347 ++++++++++++++++++ packages/core/test/session-prompt.test.ts | 2 + .../core/test/session-runner-recorded.test.ts | 3 + packages/core/test/session-runner.test.ts | 3 + packages/core/test/session-skill.test.ts | 4 +- packages/plugin/src/effect/registration.ts | 2 +- packages/plugin/src/effect/session.ts | 12 + packages/plugin/src/promise/registration.ts | 2 +- packages/plugin/src/promise/session.ts | 12 + .../src/docs/content/build/plugins/index.mdx | 32 +- 16 files changed, 549 insertions(+), 54 deletions(-) create mode 100644 packages/core/test/fixture/prompt-location.ts create mode 100644 packages/core/test/plugin-session.types.ts create mode 100644 packages/core/test/session-prompt-hooks.test.ts diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 60a28a4916b..d1ee5e0f12f 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -26,6 +26,7 @@ import { ModelResolver } from "./model-resolver.js" import { MCP } from "./mcp/index.js" import { Permission } from "./permission.js" import { Plugin } from "./plugin.js" +import { PluginHooks } from "./plugin/hooks.js" import { PluginSupervisor } from "./plugin/supervisor.js" import { Worktree } from "./worktree.js" import { Pty } from "./pty.js" @@ -69,6 +70,7 @@ const locationServiceNodes = [ ModelResolver.node, AISDK.node, Plugin.node, + PluginHooks.node, PluginSupervisor.node, Worktree.refreshNode, FileSystemSearch.node, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 80a113ed5db..e58d111d32a 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -41,6 +41,7 @@ import { Session } from "@opencode-ai/schema/session" import { FSUtil } from "@opencode-ai/util/fs-util" import { Image } from "./image.js" import { PluginSupervisor } from "./plugin/supervisor-service.js" +import { PluginHooks } from "./plugin/hooks.js" import { Mime } from "./mime.js" import type { EventLog } from "@opencode-ai/schema/event-log" import { Event } from "@opencode-ai/schema/event" @@ -640,39 +641,30 @@ const layer = Layer.effect( ), ), prompt: Effect.fn("Session.prompt")((input) => - Effect.uninterruptible( + Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const session = yield* result.get(input.sessionID) - // A staged revert must be committed before admitting new input so the prompt - // continues from the reverted boundary rather than stale post-boundary history. - if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus)) - // Resolved lazily so prompt admission only boots location services when an - // image attachment actually needs the resizer. - const image = Effect.gen(function* () { - const plugins = yield* PluginSupervisor.Service - yield* plugins.flush - return yield* Image.Service - }).pipe(Effect.provide(locations.get(session.location))) - const skills = Effect.gen(function* () { - const plugins = yield* PluginSupervisor.Service - yield* plugins.flush - return yield* Skill.Service - }).pipe(Effect.provide(locations.get(session.location))) - const prompt = yield* resolvePrompt( - { text: input.text, files: input.files, agents: input.agents, skills: input.skills }, - image, - skills, - ).pipe(Effect.provideService(FSUtil.Service, fs)) const messageID = input.id ?? SessionMessage.ID.create() - const admittedInput = SessionInbox.Item.make({ - type: "user", - payload: { ...prompt, metadata: input.metadata }, - delivery: input.delivery ?? "steer", - }) - const admitted = yield* SessionInbox.admit(db, bus, { - id: messageID, - sessionID: input.sessionID, - item: admittedInput, + const admitted = yield* Effect.gen(function* () { + const existing = yield* SessionInbox.reconcile(db, { + id: messageID, + sessionID: session.id, + delivery: input.delivery ?? "steer", + }) + if (existing) return existing + const item = yield* restore( + preparePrompt(input, messageID).pipe( + Effect.provide(locations.get(session.location)), + Effect.provideService(FSUtil.Service, fs), + ), + ) + // Commit a staged revert only after preparation succeeds, before admitting new work. + if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus)) + return yield* SessionInbox.admit(db, bus, { + id: messageID, + sessionID: session.id, + item, + }) }).pipe( Effect.catchDefect((defect) => defect instanceof SessionInbox.LifecycleConflict @@ -1018,19 +1010,34 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf } } -const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* ( - input: PromptInput.Prompt, - image: Effect.Effect, - skills: Effect.Effect, +const preparePrompt = Effect.fn("Session.preparePrompt")(function* ( + request: Parameters[0], + messageID: SessionMessage.ID, ) { + const plugins = yield* PluginSupervisor.Service + yield* plugins.flush + const hooks = yield* PluginHooks.Service + const event = yield* hooks.trigger("session", "prompt", { + sessionID: request.sessionID, + messageID, + prompt: structuredClone({ + text: request.text, + files: request.files?.slice(), + agents: request.agents?.slice(), + skills: request.skills?.slice(), + }), + metadata: structuredClone(request.metadata), + delivery: request.delivery ?? "steer", + }) + const input = event.prompt const fs = yield* FSUtil.Service const files = input.files - ? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file, image), { concurrency: 8 }) + ? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 }) : undefined const requested = input.skills const selected = yield* Effect.gen(function* () { if (!requested?.length) return undefined - const skillService = yield* skills + const skillService = yield* Skill.Service const prepared = new Map() return yield* Effect.forEach(requested, (attachment) => Effect.gen(function* () { @@ -1048,7 +1055,19 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* ( }), ) }) - return Prompt.make({ text: input.text, agents: input.agents, files, skills: selected?.length ? selected : undefined }) + return SessionInbox.Item.make({ + type: "user", + payload: { + ...Prompt.make({ + text: input.text, + agents: input.agents, + files, + skills: selected?.length ? selected : undefined, + }), + metadata: event.metadata, + }, + delivery: event.delivery, + }) }) const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 @@ -1056,7 +1075,6 @@ const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 const materializeAttachment = Effect.fn("Session.materializeAttachment")(function* ( fs: FSUtil.Interface, input: PromptInput.FileAttachment, - image: Effect.Effect, ) { const resolved = input.uri.startsWith("data:") ? { @@ -1085,7 +1103,7 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio .join("\n"), ) : resolved.bytes - const normalized = yield* normalizeImageAttachment(input, Buffer.from(content).toString("base64"), mime, image) + const normalized = yield* normalizeImageAttachment(input, Buffer.from(content).toString("base64"), mime) return FileAttachment.create({ data: normalized.data, mime: normalized.mime, @@ -1100,10 +1118,9 @@ const normalizeImageAttachment = Effect.fn("Session.normalizeImageAttachment")(f input: PromptInput.FileAttachment, data: string, mime: string, - image: Effect.Effect, ) { if (!mime.startsWith("image/")) return { data: Base64.make(data), mime } - const service = yield* image + const service = yield* Image.Service const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri) const content = { uri: label, content: data, encoding: "base64" as const, mime } const normalized = yield* service.normalize(label, content).pipe( diff --git a/packages/core/src/session/inbox.ts b/packages/core/src/session/inbox.ts index f7ec3330b8b..cbea8488331 100644 --- a/packages/core/src/session/inbox.ts +++ b/packages/core/src/session/inbox.ts @@ -134,6 +134,23 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi return yield* Effect.die(new LifecycleConflict({ id })) }) +/** Reconciles pending or delivered work without preparing a new admission payload. */ +export const reconcile = Effect.fn("SessionInbox.reconcile")(function* ( + db: DatabaseService, + request: { + readonly id: SessionMessage.ID + readonly sessionID: SessionSchema.ID + readonly delivery: Delivery + }, +) { + const existing = yield* find(db, request.id) + if (existing !== undefined) { + if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id })) + return existing + } + return yield* promotedFromMessage(db, request.sessionID, request.id, request.delivery) +}) + export const admit = Effect.fn("SessionInbox.admit")(function* ( db: DatabaseService, bus: Bus.Interface, @@ -143,13 +160,8 @@ export const admit = Effect.fn("SessionInbox.admit")(function* ( readonly item: Item }, ) { - const existing = yield* find(db, request.id) - if (existing !== undefined) { - if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id })) - return existing - } - const promoted = yield* promotedFromMessage(db, request.sessionID, request.id, request.item.delivery) - if (promoted !== undefined) return promoted + const existing = yield* reconcile(db, { ...request, delivery: request.item.delivery }) + if (existing !== undefined) return existing return yield* bus .publish(SessionEvent.InboxEnqueued, { inboxID: request.id, diff --git a/packages/core/test/fixture/prompt-location.ts b/packages/core/test/fixture/prompt-location.ts new file mode 100644 index 00000000000..9a9ff729b40 --- /dev/null +++ b/packages/core/test/fixture/prompt-location.ts @@ -0,0 +1,18 @@ +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import type { LocationServices } from "@opencode-ai/core/location-services" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { Effect, Layer, LayerMap } from "effect" + +// Plain-prompt unit fixtures use virtual directories and need only the admission hook services. +export const promptLocationLayer = Layer.effect( + LocationServiceMap.Service, + LayerMap.make( + () => + Layer.merge( + LayerNode.compile(PluginHooks.node), + Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }), + ) as Layer.Layer, + ), +) diff --git a/packages/core/test/plugin-session.types.ts b/packages/core/test/plugin-session.types.ts new file mode 100644 index 00000000000..25bec4ef994 --- /dev/null +++ b/packages/core/test/plugin-session.types.ts @@ -0,0 +1,32 @@ +import type { Context } from "@opencode-ai/plugin/effect/plugin" +import type { SessionDomain } from "@opencode-ai/plugin/promise/session" +import { Session } from "@opencode-ai/schema/session" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Effect } from "effect" + +export function effectPrompt(context: Context) { + context.session.hook("prompt", (event) => + Effect.sync(() => { + event.prompt.files ??= [] + event.prompt.files.push({ uri: "file:///policy.md" }) + event.delivery = "queue" + // @ts-expect-error Admission identity cannot be rewritten. + event.sessionID = Session.ID.make("ses_other") + }), + ) + // @ts-expect-error Prompt admission has no resolved model to filter by provider. + context.session.hook("prompt", () => Effect.void, { providerID: "openai" }) + context.session.hook("context", () => Effect.void, { providerID: "openai" }) +} + +export function promisePrompt(session: SessionDomain) { + session.hook("prompt", (event) => { + event.prompt.text = "Prepared" + event.metadata = { source: "plugin" } + // @ts-expect-error Admission identity cannot be rewritten. + event.messageID = SessionMessage.ID.make("msg_other") + }) + // @ts-expect-error Prompt admission has no resolved model to filter by provider. + session.hook("prompt", () => {}, { providerID: "openai" }) + session.hook("context", () => {}, { providerID: "openai" }) +} diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index c224989c689..b6e70ddd88a 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -33,6 +33,8 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { SessionTransfer } from "@opencode-ai/core/session/transfer" import { Workspace } from "@opencode-ai/core/workspace" import { testEffect } from "./lib/effect" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { promptLocationLayer } from "./fixture/prompt-location" import { globalProjectLayer } from "./lib/project" import { tmpdir } from "./fixture/tmpdir" @@ -50,6 +52,7 @@ const it = testEffect( [ [Bus.node, Bus.configured({ persist: true })], [Project.node, globalProjectLayer], + [LocationServiceMap.node, promptLocationLayer], [SessionExecution.node, SessionExecution.noopLayer], ], ), @@ -963,7 +966,7 @@ describe("Session.create", () => { }), ) - it.live("runs a shell command and projects the started/ended shell message", () => + liveIt.live("runs a shell command and projects the started/ended shell message", () => withTmp((directory) => Effect.gen(function* () { const session = yield* Session.Service @@ -988,7 +991,7 @@ describe("Session.create", () => { ), ) - it.live("still emits shell ended for a failing command", () => + liveIt.live("still emits shell ended for a failing command", () => withTmp((directory) => Effect.gen(function* () { const session = yield* Session.Service diff --git a/packages/core/test/session-prompt-hooks.test.ts b/packages/core/test/session-prompt-hooks.test.ts new file mode 100644 index 00000000000..b3c4c337b61 --- /dev/null +++ b/packages/core/test/session-prompt-hooks.test.ts @@ -0,0 +1,347 @@ +import { describe, expect } from "bun:test" +import path from "path" +import { Deferred, Effect, Fiber, Stream } from "effect" +import { Bus } from "@opencode-ai/core/bus" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" +import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Session } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionInbox } from "@opencode-ai/core/session/inbox" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { Skill } from "@opencode-ai/core/skill" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { Global } from "@opencode-ai/util/global" +import { tempGlobalLayer } from "./fixture/global" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const runtime = PluginRuntime.makeCell() +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + Bus.node, + SessionProjector.node, + Session.node, + LocationServiceMap.node, + PluginRuntime.providerNodeWithCell(runtime), + ]), + [ + [Bus.node, Bus.configured({ persist: true })], + [Global.node, tempGlobalLayer], + [SessionExecution.node, SessionExecution.noopLayer], + [PluginRuntime.node, PluginRuntime.layerWithCell(runtime)], + ], + ), +) + +const project = Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), +) + +const setup = Effect.gen(function* () { + const tmp = yield* project + const sessions = yield* Session.Service + const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } }) + const locations = yield* LocationServiceMap.Service + const services = locations.get(session.location) + const hooks = yield* Effect.gen(function* () { + const plugins = yield* PluginSupervisor.Service + yield* plugins.flush + return yield* PluginHooks.Service + }).pipe(Effect.provide(services)) + return { sessions, session, hooks, services } +}) + +describe("Session prompt hooks", () => { + it.live("waits for local plugin setup before admitting even a plain-text prompt", () => + Effect.gen(function* () { + const tmp = yield* project + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, ".opencode/plugins/prompt.ts"), + `export default { + id: "prompt-readiness", + async setup(ctx) { + await ctx.session.hook("prompt", (event) => { + event.prompt.text = "Prepared by plugin" + }) + }, + }`, + ), + ) + const sessions = yield* Session.Service + const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } }) + const admitted = yield* sessions.prompt({ sessionID: session.id, text: "Original", resume: false }) + expect(admitted.payload.text).toBe("Prepared by plugin") + }), + ) + + it.live("persists ordered draft edits and resolves added files and skills without mutating the caller", () => + Effect.gen(function* () { + const fixture = yield* setup + const skills = yield* Skill.Service.pipe(Effect.provide(fixture.services)) + const skill = Skill.Info.make({ + id: Skill.ID.make("policy"), + name: Skill.Name.make("Policy"), + description: "Company policy", + location: AbsolutePath.make(path.join(fixture.session.location.directory, "policy.md")), + content: "Follow company policy.", + }) + yield* skills.transform((draft) => draft.add(skill)) + const input = { + sessionID: fixture.session.id, + id: SessionMessage.ID.create(), + text: "secret", + files: [ + { + uri: "data:text/plain;base64,b3JpZ2luYWw=", + name: "original.txt", + mention: { start: 0, end: 6, text: "secret" }, + }, + ], + metadata: { source: "api" }, + resume: false, + } + yield* fixture.hooks.register("session", "prompt", (event) => + Effect.sync(() => { + expect(event.sessionID).toBe(input.sessionID) + expect(event.messageID).toBe(input.id) + event.prompt.text = "Redacted" + const file = event.prompt.files?.[0] + if (file) { + file.uri = "data:text/plain;base64,cG9saWN5" + file.name = "policy.txt" + delete file.mention + } + event.prompt.skills = [{ id: skill.id }] + event.prompt.agents = [{ name: "reviewer" }] + event.metadata ??= {} + event.metadata.source = "plugin" + event.delivery = "queue" + }), + ) + yield* fixture.hooks.register("session", "prompt", (event) => + Effect.sync(() => { + expect(event.prompt.text).toBe("Redacted") + event.prompt.text += " with policy" + }), + ) + const admitted = yield* fixture.sessions.prompt(input) + expect(admitted).toMatchObject({ + id: input.id, + delivery: "queue", + payload: { + text: "Redacted with policy", + metadata: { source: "plugin" }, + files: [{ name: "policy.txt", data: "cG9saWN5", mime: "text/plain" }], + agents: [{ name: "reviewer" }], + skills: [{ id: skill.id, name: skill.name, text: Skill.toModelOutput(skill, []) }], + }, + }) + expect(input.text).toBe("secret") + expect(input.files).toEqual([ + { + uri: "data:text/plain;base64,b3JpZ2luYWw=", + name: "original.txt", + mention: { start: 0, end: 6, text: "secret" }, + }, + ]) + expect(input.metadata).toEqual({ source: "api" }) + const database = yield* Database.Service + const bus = yield* Bus.Service + expect(yield* SessionInbox.find(database.db, input.id)).toEqual(admitted) + const log = yield* fixture.sessions.log({ sessionID: input.sessionID }).pipe(Stream.runCollect) + expect(JSON.stringify(log)).not.toContain("secret") + yield* SessionInbox.promote(database.db, bus, input.sessionID, "input") + expect(yield* fixture.sessions.messages({ sessionID: input.sessionID })).toMatchObject([ + { id: input.id, type: "user", text: "Redacted with policy", metadata: { source: "plugin" } }, + ]) + }), + ) + + it.live("skips hooks and payload resolution on pending and delivered retries, including conflicts", () => + Effect.gen(function* () { + const fixture = yield* setup + const calls: string[] = [] + yield* fixture.hooks.register("session", "prompt", (event) => + Effect.sync(() => { + calls.push(event.prompt.text) + event.prompt.text = "First admission" + }), + ) + const input = { sessionID: fixture.session.id, id: SessionMessage.ID.create(), text: "Original", resume: false } + const first = yield* fixture.sessions.prompt(input) + const retry = { ...input, text: "Ignored", files: [{ uri: "file:///missing-retry-file" }] } + expect(yield* fixture.sessions.prompt(retry)).toEqual(first) + const database = yield* Database.Service + const bus = yield* Bus.Service + yield* SessionInbox.promote(database.db, bus, input.sessionID, "steer") + expect((yield* fixture.sessions.prompt(retry)).payload).toEqual(first.payload) + const other = yield* fixture.sessions.create({ location: fixture.session.location }) + expect((yield* fixture.sessions.prompt({ ...retry, sessionID: other.id }).pipe(Effect.flip))._tag).toBe( + "Session.PromptConflictError", + ) + const synthetic = yield* fixture.sessions.synthetic({ + sessionID: input.sessionID, + text: "Synthetic", + resume: false, + }) + expect((yield* fixture.sessions.prompt({ ...retry, id: synthetic.id }).pipe(Effect.flip))._tag).toBe( + "Session.PromptConflictError", + ) + expect(calls).toEqual(["Original"]) + }), + ) + + it.live("leaves a staged revert untouched on retries and failed preparation", () => + Effect.gen(function* () { + const fixture = yield* setup + const database = yield* Database.Service + const bus = yield* Bus.Service + const first = yield* fixture.sessions.prompt({ sessionID: fixture.session.id, text: "Boundary", resume: false }) + yield* SessionInbox.promote(database.db, bus, fixture.session.id, "steer") + yield* bus.publish(SessionEvent.RevertEvent.Staged, { + sessionID: fixture.session.id, + revert: { messageID: first.id, files: [] }, + }) + const failing = yield* fixture.hooks.register("session", "prompt", () => Effect.die(new Error("Broken hook"))) + expect( + (yield* fixture.sessions.prompt({ + sessionID: fixture.session.id, + id: first.id, + text: "Ignored", + resume: false, + })).payload, + ).toEqual(first.payload) + expect( + (yield* fixture.sessions + .prompt({ sessionID: fixture.session.id, text: "Fail", resume: false }) + .pipe(Effect.exit))._tag, + ).toBe("Failure") + expect((yield* fixture.sessions.get(fixture.session.id)).revert?.messageID).toBe(first.id) + expect(yield* fixture.sessions.messages({ sessionID: fixture.session.id })).toMatchObject([{ id: first.id }]) + yield* failing.dispose + const next = yield* fixture.sessions.prompt({ + sessionID: fixture.session.id, + text: "After revert", + resume: false, + }) + expect((yield* fixture.sessions.get(fixture.session.id)).revert).toBeUndefined() + expect(yield* fixture.sessions.messages({ sessionID: fixture.session.id })).toEqual([]) + expect(yield* fixture.sessions.inbox(fixture.session.id)).toEqual([next]) + }), + ) + + it.live("keeps first-admission-wins for concurrent transformed submissions", () => + Effect.gen(function* () { + const fixture = yield* setup + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const calls: string[] = [] + yield* fixture.hooks.register("session", "prompt", (event) => + Effect.gen(function* () { + calls.push(event.prompt.text) + event.prompt.text += " transformed" + if (calls.length === 2) yield* Deferred.succeed(entered, undefined) + yield* Deferred.await(release) + }), + ) + const input = { sessionID: fixture.session.id, id: SessionMessage.ID.create(), text: "First", resume: false } + const submissions = yield* Effect.all( + [fixture.sessions.prompt(input), fixture.sessions.prompt({ ...input, text: "Second" })], + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild) + yield* Deferred.await(entered) + expect(yield* fixture.sessions.inbox(input.sessionID)).toEqual([]) + yield* Deferred.succeed(release, undefined) + const results = yield* Fiber.join(submissions) + expect(results[0]).toEqual(results[1]) + expect(["First transformed", "Second transformed"]).toContain(results[0]?.payload.text) + expect(yield* fixture.sessions.inbox(input.sessionID)).toHaveLength(1) + expect(yield* fixture.sessions.prompt(input)).toEqual(results[0]) + expect(calls).toHaveLength(2) + }), + ) + + it.live("does not admit failed attachment preparation or an interrupted hook", () => + Effect.gen(function* () { + const fixture = yield* setup + const registration = yield* fixture.hooks.register("session", "prompt", (event) => + Effect.sync(() => { + event.prompt.files = [{ uri: "file:///missing-hook-file" }] + }), + ) + expect( + (yield* fixture.sessions + .prompt({ sessionID: fixture.session.id, text: "Original", resume: false }) + .pipe(Effect.flip))._tag, + ).toBe("Session.AttachmentError") + yield* registration.dispose + const failing = yield* fixture.hooks.register("session", "prompt", () => Effect.die(new Error("Broken hook"))) + expect( + (yield* fixture.sessions + .prompt({ sessionID: fixture.session.id, text: "Fail", resume: false }) + .pipe(Effect.exit))._tag, + ).toBe("Failure") + yield* failing.dispose + const started = yield* Deferred.make() + yield* fixture.hooks.register("session", "prompt", () => + Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + ) + const submission = yield* fixture.sessions + .prompt({ sessionID: fixture.session.id, text: "Interrupt", resume: false }) + .pipe(Effect.forkChild) + yield* Deferred.await(started) + yield* Fiber.interrupt(submission) + expect(yield* fixture.sessions.inbox(fixture.session.id)).toEqual([]) + expect(yield* fixture.sessions.messages({ sessionID: fixture.session.id })).toEqual([]) + }), + ) + + it.live("applies a Promise plugin to command-generated prompts only in its own location", () => + Effect.gen(function* () { + const tmp = yield* project + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, ".opencode/plugins/command.ts"), + `export default { + id: "prompt-command", + async setup(ctx) { + await ctx.session.hook("prompt", (event) => { + event.prompt.text += " with plugin" + }) + await ctx.command.transform((draft) => { + draft.add({ + name: "review", + async execute(input) { + await ctx.session.prompt({ sessionID: input.sessionID, text: "Review", resume: false }) + }, + }) + }) + }, + }`, + ), + ) + const sessions = yield* Session.Service + const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } }) + const other = yield* setup + yield* sessions.command({ sessionID: session.id, command: "review", text: "" }) + expect(yield* sessions.inbox(session.id)).toMatchObject([{ payload: { text: "Review with plugin" } }]) + const untouched = yield* other.sessions.prompt({ + sessionID: other.session.id, + text: "Other location", + resume: false, + }) + expect(untouched.payload.text).toBe("Other location") + }), + ) +}) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 513791ee9a9..d6fd6241fce 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -28,6 +28,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import type { LocationServices } from "@opencode-ai/core/location-services" import { Image } from "@opencode-ai/core/image" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { Snapshot } from "@opencode-ai/core/snapshot" import { testEffect } from "./lib/effect" @@ -67,6 +68,7 @@ const locations = Layer.effect( Effect.sync(() => { let ready = false return Layer.mergeAll( + LayerNode.compile(PluginHooks.node), Layer.mock(Image.Service, { normalize: (_resource, content) => ready diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 35110967e34..c7c8c64cb5e 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -44,6 +44,8 @@ import { Effect, Layer, Stream } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import path from "node:path" import { testEffect } from "./lib/effect" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { promptLocationLayer } from "./fixture/prompt-location" import { permissionLayer } from "./lib/permission" import { agentHost, catalogHost, host } from "./plugin/host" @@ -155,6 +157,7 @@ const testLayer = (llmClient: Layer.Layer) => ]), [ [Bus.node, Bus.configured({ persist: true })], + [LocationServiceMap.node, promptLocationLayer], [LayerNodePlatform.llmClient, llmClient], [Permission.node, permission], [Catalog.node, promptCatalog], diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index bb0d1621429..5c96f381ded 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -81,6 +81,8 @@ import { TestClock } from "effect/testing" import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { asc, desc, eq, sql } from "drizzle-orm" import { testEffect } from "./lib/effect" +import { promptLocationLayer } from "./fixture/prompt-location" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { permissionLayer } from "./lib/permission" import { agentHost, catalogHost, host } from "./plugin/host" import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions" @@ -467,6 +469,7 @@ const it = testEffect( ]), [ [Bus.node, Bus.configured({ persist: true })], + [LocationServiceMap.node, promptLocationLayer], [LayerNodePlatform.llmClient, client], [Permission.node, permission], [Catalog.node, promptCatalog], diff --git a/packages/core/test/session-skill.test.ts b/packages/core/test/session-skill.test.ts index 7f659287e12..8e560d9129f 100644 --- a/packages/core/test/session-skill.test.ts +++ b/packages/core/test/session-skill.test.ts @@ -10,6 +10,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import type { LocationServices } from "@opencode-ai/core/location-services" import { Project } from "@opencode-ai/core/project" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { AbsolutePath } from "@opencode-ai/core/schema" import { Session } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -31,7 +32,8 @@ const info = Skill.Info.make({ location: AbsolutePath.make(path.resolve("/skills/effect.md")), content: "Use Effect", }) -const skills = Layer.merge( +const skills = Layer.mergeAll( + LayerNode.compile(PluginHooks.node), Layer.mock(Skill.Service, { get: (id) => Effect.succeed(id === info.id ? info : undefined), list: () => Effect.succeed([info]), diff --git a/packages/plugin/src/effect/registration.ts b/packages/plugin/src/effect/registration.ts index 174d2d49d31..e8ed7987537 100644 --- a/packages/plugin/src/effect/registration.ts +++ b/packages/plugin/src/effect/registration.ts @@ -21,7 +21,7 @@ export type ModelHooks = Reco >( name: Name, callback: (input: Spec[Name]) => Effect.Effect, - options?: ModelHookOptions, + options?: Spec[Name] extends { readonly model: unknown } ? ModelHookOptions : never, ) => Effect.Effect export type Transform = (callback: (input: Input) => void) => Effect.Effect diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts index ec3d077b63f..27f1c8de820 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/session.ts @@ -2,10 +2,21 @@ import type { SessionApi } from "@opencode-ai/client/effect/api" import type { GenerationOptionsFields, Message, SystemPart } from "@opencode-ai/ai" import type { Agent } from "@opencode-ai/schema/agent" import type { Model } from "@opencode-ai/schema/model" +import type { PromptInput } from "@opencode-ai/schema/prompt-input" import type { Session } from "@opencode-ai/schema/session" +import type { SessionInbox } from "@opencode-ai/schema/session-inbox" +import type { SessionMessage } from "@opencode-ai/schema/session-message" import type { JsonSchema, Types } from "effect" import type { ModelHooks } from "./registration.js" +export interface SessionPrompt { + readonly sessionID: Session.ID + readonly messageID: SessionMessage.ID + prompt: Types.DeepMutable + metadata?: Record + delivery: SessionInbox.Delivery +} + export interface SessionContext { readonly sessionID: Session.ID readonly agent: Agent.ID @@ -42,6 +53,7 @@ export interface SessionHttpResponse { } export interface SessionHooks { + readonly prompt: SessionPrompt readonly context: SessionContext readonly "model.request": SessionModelRequest readonly "http.request": SessionHttpRequest diff --git a/packages/plugin/src/promise/registration.ts b/packages/plugin/src/promise/registration.ts index 1886496b8da..3c2f8048fca 100644 --- a/packages/plugin/src/promise/registration.ts +++ b/packages/plugin/src/promise/registration.ts @@ -15,7 +15,7 @@ export type Hooks = ( export type ModelHooks = ( name: Name, callback: (input: Spec[Name]) => Promise | void, - options?: ModelHookOptions, + options?: Spec[Name] extends { readonly model: unknown } ? ModelHookOptions : never, ) => Promise export type Transform = (callback: (input: Input) => void) => Promise diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index 325c8393790..894cc0daaa0 100644 --- a/packages/plugin/src/promise/session.ts +++ b/packages/plugin/src/promise/session.ts @@ -2,10 +2,21 @@ import type { SessionApi } from "@opencode-ai/client/promise/api" import type { GenerationOptionsFields, Message, SystemPart } from "@opencode-ai/ai" import type { Agent } from "@opencode-ai/schema/agent" import type { Model } from "@opencode-ai/schema/model" +import type { PromptInput } from "@opencode-ai/schema/prompt-input" import type { Session } from "@opencode-ai/schema/session" +import type { SessionInbox } from "@opencode-ai/schema/session-inbox" +import type { SessionMessage } from "@opencode-ai/schema/session-message" import type { JsonSchema, Types } from "effect" import type { ModelHooks } from "./registration.js" +export interface SessionPrompt { + readonly sessionID: Session.ID + readonly messageID: SessionMessage.ID + prompt: Types.DeepMutable + metadata?: Record + delivery: SessionInbox.Delivery +} + export interface SessionContext { readonly sessionID: Session.ID readonly agent: Agent.ID @@ -42,6 +53,7 @@ export interface SessionHttpResponse { } export interface SessionHooks { + readonly prompt: SessionPrompt readonly context: SessionContext readonly "model.request": SessionModelRequest readonly "http.request": SessionHttpRequest diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index 8dc27afedc9..989b2ce55fd 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -1011,6 +1011,33 @@ await registration.dispose() ### Sessions +Intercept incoming user prompts before attachment and skill resolution and durable inbox admission: + +```ts +await ctx.session.hook("prompt", (event) => { + event.prompt.text = event.prompt.text.replaceAll("company-secret", "[redacted]") + event.prompt.files ??= [] + event.prompt.files.push({ uri: "file:///project/policy.md" }) + event.metadata = { ...event.metadata, source: "company-policy" } + event.delivery = "queue" +}) +``` + +The hook receives an owned, mutable draft with `text`, `files`, agent mentions (`agents`), and selected `skills`, plus +`metadata` and `delivery` (`"steer"` by default, or `"queue"`). Files and skills added by hooks use the normal resolution +and validation path. If you rewrite text, update or remove attachment `mention` offsets that no longer match it. Agent +mentions do not switch the session's active agent. Session and message IDs are readonly and cannot redirect admission. + +Hooks run in registration order after the session's location plugins are ready. Edits become the canonical persisted user +input; unlike `context`, `prompt` does not run on each model call. Commands that submit through `session.prompt` also run +this hook. Synthetic messages, shell messages, and compaction/move controls do not. Prompt hooks cannot be scoped by model +provider because admission is separate from model resolution. Use an unscoped registration. + +Retries of an ID already pending or delivered return the original admission without rerunning hooks or resolving the +retried payload. Concurrent submissions can run hooks more than once before either admission commits, but only the first +successful admission wins. Keep hooks retry-safe; they are not an exactly-once side-effect boundary. A failed or interrupted +preparation admits no prompt. This hook transforms input and does not expose a typed rejection API. + Modify assembled system instructions, messages, tools, generation settings, or provider options immediately before model dispatch. @@ -1080,7 +1107,10 @@ await ctx.session.hook("http.response", (event) => { #### Reference ```ts +import type { SessionPrompt } from "@opencode-ai/plugin/promise/session" + interface SessionHooks { + prompt: SessionPrompt context: SessionContextHook "model.request": SessionModelRequestHook "http.request": SessionHttpRequestHook @@ -1111,7 +1141,7 @@ interface SessionHookContext { hook( name: Name, callback: (event: SessionHooks[Name]) => Promise | void, - options?: { providerID?: string }, + options?: Name extends "prompt" ? never : { providerID?: string }, ): Promise } ```