feat(plugin): add session options hook

This commit is contained in:
Aiden Cline 2026-07-18 17:00:43 +00:00 committed by 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
commit d927f6fa8d
18 changed files with 320 additions and 57 deletions

View file

@ -16,6 +16,10 @@ export interface Domains {
type Callback<Event> = (event: Event) => Effect.Effect<void>
export interface Interface {
readonly has: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
domain: Domain,
name: Name,
) => boolean
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
domain: Domain,
name: Name,
@ -60,7 +64,7 @@ const layer = Layer.effect(
return event
})
return Service.of({ register, trigger })
return Service.of({ has: (domain, name) => callbacks.has(key(domain, name)), register, trigger })
}),
)

View file

@ -10,6 +10,7 @@ import { SessionContext } from "./context"
import { SessionGenerate } from "./generate"
import { SessionHistory } from "./history"
import { SessionModelHeaders } from "./model-headers"
import { SessionOptionsHook } from "./options-hook"
import { SessionRunnerModel } from "./runner/model"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
@ -46,7 +47,11 @@ const layer = Layer.effect(
],
tools: {},
})
return (yield* llm.generate(
return (yield* SessionOptionsHook.client(llm, hooks, {
sessionID: selection.session.id,
agent: selection.agent.id,
model: model.ref,
}).generate(
LLM.request({
model: model.model,
http: { headers: SessionModelHeaders.make(selection.session) },

View file

@ -0,0 +1,26 @@
export * as SessionOptionsHook from "./options-hook"
import { LLM } from "@opencode-ai/ai"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { Effect } from "effect"
import { PluginHooks } from "../plugin/hooks"
type Identity = Pick<SessionHooks["options"], "sessionID" | "agent" | "model">
export const client = (llm: LLMClientShape, hooks: PluginHooks.Interface, identity: Identity) =>
hooks.has("session", "options")
? llm.withOptionsTransform((request) =>
Effect.gen(function* () {
const event = yield* hooks.trigger("session", "options", {
...identity,
generation: { ...request.generation },
providerOptions: structuredClone(request.providerOptions ?? {}),
})
return LLM.updateRequest(request, {
generation: event.generation,
providerOptions: event.providerOptions,
})
}),
)
: llm

View file

@ -6,6 +6,7 @@ import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { PermissionV2 } from "../../permission"
import { PluginHooks } from "../../plugin/hooks"
import { QuestionTool } from "../../tool/question"
import { ToolOutputStore } from "../../tool-output-store"
import { InstructionState } from "../instruction-state"
@ -13,6 +14,7 @@ import { SessionCompaction } from "../compaction"
import { SessionContext } from "../context"
import { SessionEvent } from "../event"
import { SessionPending } from "../pending"
import { SessionOptionsHook } from "../options-hook"
import { SessionModelRequest } from "../model-request"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
@ -40,6 +42,7 @@ const layer = Layer.effect(
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const title = yield* SessionTitle.Service
const hooks = yield* PluginHooks.Service
// Title generation is a side effect of the first step; it must not delay step continuation.
// Tracked per process so repeated wakes before the second user message arrives don't
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
@ -132,7 +135,12 @@ const layer = Layer.effect(
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(prepared.request).pipe(
const sessionLLM = SessionOptionsHook.client(llm, hooks, {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
})
const providerStream = sessionLLM.stream(prepared.request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
@ -482,5 +490,6 @@ export const node = makeLocationNode({
SessionTitle.node,
Snapshot.node,
Database.node,
PluginHooks.node,
],
})

View file

@ -218,6 +218,38 @@ describe("fromPromise", () => {
}),
)
it.effect("forwards session option hooks", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise(
Plugin.define({
id: "promise-session-options",
setup: async (ctx) => {
await ctx.session.hook("options", (event) => {
event.generation.temperature = 0.3
event.providerOptions.openai ??= {}
event.providerOptions.openai.reasoningEffort = "high"
})
},
}),
).effect(host)
const event: SessionHooks["options"] = {
sessionID: SessionV2.ID.make("ses_promise_session_options"),
agent: AgentV2.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
generation: { temperature: 0.7 },
providerOptions: {},
}
yield* hooks.trigger("session", "options", event)
expect(event.generation.temperature).toBe(0.3)
expect(event.providerOptions).toEqual({ openai: { reasoningEffort: "high" } })
}),
)
it.effect("disposes a hook registration on request", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service

View file

@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -40,14 +41,16 @@ const projects = Layer.succeed(
}),
)
let requests: LLMRequest[] = []
const client = Layer.mock(LLMClient.Service)({
const clientValue: LLMClientShape = {
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual session summary" }))
},
generate: () => Effect.die("unused"),
})
withOptionsTransform: () => clientValue,
}
const client = Layer.mock(LLMClient.Service)(clientValue)
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const locations = Layer.effect(

View file

@ -1,6 +1,7 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -42,7 +43,7 @@ const cost = [
},
},
]
const client = Layer.mock(LLMClient.Service)({
const clientValue: LLMClientShape = {
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
@ -66,7 +67,9 @@ const client = Layer.mock(LLMClient.Service)({
)
},
generate: () => Effect.die("unused"),
})
withOptionsTransform: () => clientValue,
}
const client = Layer.mock(LLMClient.Service)(clientValue)
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),

View file

@ -1,6 +1,7 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -47,7 +48,7 @@ let instruction: string | Instructions.Unavailable = "Initial context"
const sessionID = SessionSchema.ID.make("ses_generate_test")
const model = Model.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
const client = Layer.mock(LLMClient.Service)({
const clientValue: LLMClientShape = {
prepare: () => Effect.die(new Error("unused")),
stream: () => Stream.die(new Error("unused")),
generate: (request) =>
@ -64,7 +65,9 @@ const client = Layer.mock(LLMClient.Service)({
if (!response) throw new Error("Incomplete generate response")
return response
}),
})
withOptionsTransform: () => clientValue,
}
const client = Layer.mock(LLMClient.Service)(clientValue)
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const builtins = Layer.mock(InstructionBuiltIns.Service, {
load: () =>

View file

@ -0,0 +1,57 @@
import { expect } from "bun:test"
import { LLM, LLMClient, Model as LLMModel } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import type { LLMClientShape, OptionsTransform } from "@opencode-ai/ai/route"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Session } from "@opencode-ai/schema/session"
import { Effect, Layer, Stream } from "effect"
import { PluginHooks } from "../src/plugin/hooks"
import { SessionOptionsHook } from "../src/session/options-hook"
import { testEffect } from "./lib/effect"
const layer = PluginHooks.node.implementation as Layer.Layer<PluginHooks.Service>
const it = testEffect(layer)
it.effect("applies session option hooks to resolved model options", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const sessionID = Session.ID.make("ses_options")
const agent = Agent.ID.make("build")
const model = Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") })
yield* hooks.register("session", "options", (event) =>
Effect.sync(() => {
expect(event).toMatchObject({ sessionID, agent, model })
event.generation.temperature = 0.2
delete event.generation.topP
event.providerOptions.openai ??= {}
event.providerOptions.openai.reasoningEffort = "high"
}),
)
let transform: OptionsTransform | undefined
const llm: LLMClientShape = {
prepare: () => Effect.die("unused"),
stream: () => Stream.die("unused"),
generate: () => Effect.die("unused"),
withOptionsTransform: (next) => {
transform = next
return llm
},
}
SessionOptionsHook.client(llm, hooks, { sessionID, agent, model })
if (!transform) return yield* Effect.die("options transform was not installed")
const result = yield* transform(
LLM.request({
model: LLMModel.make({ id: "model", provider: "test", route: OpenAIChat.route }),
generation: { temperature: 0.7, topP: 0.9 },
providerOptions: { openai: { promptCacheKey: "cache" } },
}),
)
expect(result.generation).toMatchObject({ temperature: 0.2 })
expect(result.generation?.topP).toBeUndefined()
expect(result.providerOptions).toEqual({ openai: { promptCacheKey: "cache", reasoningEffort: "high" } })
}),
)

View file

@ -90,9 +90,7 @@ let toolExecutionsStarted: Deferred.Deferred<void> | undefined
let toolExecutionsReady = 5
let activeToolExecutions = 0
let maxActiveToolExecutions = 0
const client = Layer.succeed(
LLMClient.Service,
LLMClient.Service.of({
const clientValue: LLMClientShape = LLMClient.Service.of({
prepare: () => Effect.die("unused"),
stream: ((request: LLMRequest) => {
requests.push(request)
@ -114,8 +112,9 @@ const client = Layer.succeed(
)
}) as unknown as LLMClientShape["stream"],
generate: () => Effect.die("unused"),
}),
)
withOptionsTransform: () => clientValue,
})
const client = Layer.succeed(LLMClient.Service, clientValue)
const reply = {
stop: () => [
LLMEvent.stepStart({ index: 0 }),

View file

@ -1,6 +1,7 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -40,7 +41,7 @@ const cost = [
},
},
]
const client = Layer.mock(LLMClient.Service)({
const clientValue: LLMClientShape = {
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
@ -64,7 +65,9 @@ const client = Layer.mock(LLMClient.Service)({
)
},
generate: () => Effect.die("unused"),
})
withOptionsTransform: () => clientValue,
}
const client = Layer.mock(LLMClient.Service)(clientValue)
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
})