mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 20:52:01 +03:00
feat(plugin): intercept user prompts before admission (#45550)
This commit is contained in:
committed by
GitHub
Unverified
parent
46aa25f236
commit
23f0ee53cf
@@ -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,
|
||||
|
||||
@@ -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<Image.Interface>,
|
||||
skills: Effect.Effect<Skill.Interface>,
|
||||
const preparePrompt = Effect.fn("Session.preparePrompt")(function* (
|
||||
request: Parameters<Interface["prompt"]>[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<Skill.ID, Skill.Name>()
|
||||
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<Image.Interface>,
|
||||
) {
|
||||
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<Image.Interface>,
|
||||
) {
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<LocationServices>,
|
||||
),
|
||||
)
|
||||
@@ -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" })
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
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<void>()
|
||||
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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof LLMClient.Service>) =>
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[LocationServiceMap.node, promptLocationLayer],
|
||||
[LayerNodePlatform.llmClient, llmClient],
|
||||
[Permission.node, permission],
|
||||
[Catalog.node, promptCatalog],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -21,7 +21,7 @@ export type ModelHooks<Spec, Failures extends Record<keyof Spec, unknown> = Reco
|
||||
>(
|
||||
name: Name,
|
||||
callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
|
||||
options?: ModelHookOptions,
|
||||
options?: Spec[Name] extends { readonly model: unknown } ? ModelHookOptions : never,
|
||||
) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
export type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
@@ -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<PromptInput.Prompt>
|
||||
metadata?: Record<string, unknown>
|
||||
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
|
||||
|
||||
@@ -15,7 +15,7 @@ export type Hooks<Spec> = <Name extends keyof Spec>(
|
||||
export type ModelHooks<Spec> = <Name extends keyof Spec>(
|
||||
name: Name,
|
||||
callback: (input: Spec[Name]) => Promise<void> | void,
|
||||
options?: ModelHookOptions,
|
||||
options?: Spec[Name] extends { readonly model: unknown } ? ModelHookOptions : never,
|
||||
) => Promise<Registration>
|
||||
|
||||
export type Transform<Input> = (callback: (input: Input) => void) => Promise<Registration>
|
||||
|
||||
@@ -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<PromptInput.Prompt>
|
||||
metadata?: Record<string, unknown>
|
||||
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
|
||||
|
||||
@@ -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 extends keyof SessionHooks>(
|
||||
name: Name,
|
||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||
options?: { providerID?: string },
|
||||
options?: Name extends "prompt" ? never : { providerID?: string },
|
||||
): Promise<Registration>
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user