feat(plugin): add session request hook

This commit is contained in:
Aiden Cline
2026-07-16 17:48:21 +00:00
committed by 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
parent ad8e6b1fb6
commit 0e864b223f
21 changed files with 284 additions and 56 deletions
+24 -6
View File
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor"
import { Framing } from "./framing"
import { HttpTransport } from "./transport"
import type { Transport, TransportRuntime } from "./transport"
import type { RequestTransform, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
@@ -155,6 +155,7 @@ export interface Interface {
readonly prepare: <Body = unknown>(request: LLMRequest) => Effect.Effect<PreparedRequestOf<Body>, LLMError>
readonly stream: StreamMethod
readonly generate: GenerateMethod
readonly withRequestTransform: (transform: RequestTransform) => Interface
}
export interface StreamMethod {
@@ -411,6 +412,22 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =
}),
)
const makeClient = (runtime: TransportRuntime): Interface => {
const stream = streamRequestWith(runtime)
return {
prepare: prepareWith as Interface["prepare"],
stream,
generate: generateWith(stream),
withRequestTransform: (transform) =>
makeClient({
...runtime,
transformRequest: runtime.transformRequest
? (request) => runtime.transformRequest!(request).pipe(Effect.flatMap(transform))
: transform,
}),
}
}
const generateWith = (stream: Interface["stream"]) =>
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
@@ -449,11 +466,12 @@ export const streamRequest = (request: LLMRequest) =>
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const stream = streamRequestWith({
http: yield* RequestExecutor.Service,
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
})
return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
return Service.of(
makeClient({
http: yield* RequestExecutor.Service,
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
}),
)
}),
)
+1 -1
View File
@@ -22,4 +22,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol"
export type { Transport as TransportDef, TransportRuntime } from "./transport"
export type { RequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
+26 -13
View File
@@ -5,7 +5,7 @@ import { render as renderEndpoint } from "../endpoint"
import { Framing } from "../framing"
import type { Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared"
import { mergeJsonRecords, type LLMRequest } from "../../schema"
import { InvalidRequestReason, LLMError, mergeJsonRecords, type LLMRequest } from "../../schema"
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
@@ -130,23 +130,36 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
),
frames: (prepared, request, runtime) =>
Stream.unwrap(
runtime.http
.execute(prepared.request)
.pipe(
Effect.map((response) =>
prepared.framing.frame(
response.stream.pipe(
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
ProviderShared.errorText(error),
),
(runtime.transformRequest
? HttpClientRequest.toWeb(prepared.request).pipe(
Effect.mapError(
(error) =>
new LLMError({
module: "HttpTransport",
method: "frames",
reason: new InvalidRequestReason({ message: error.message }),
}),
),
Effect.flatMap(runtime.transformRequest),
Effect.map((request) => HttpClientRequest.fromWeb(request.clone() as Request)),
Effect.flatMap(runtime.http.execute),
)
: runtime.http.execute(prepared.request)
).pipe(
Effect.map((response) =>
prepared.framing.frame(
response.stream.pipe(
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
ProviderShared.errorText(error),
),
),
),
),
),
),
),
})
+3
View File
@@ -8,8 +8,11 @@ import type { LLMError, LLMRequest } from "../../schema"
export interface TransportRuntime {
readonly http: RequestExecutorInterface
readonly webSocket?: WebSocketExecutorInterface
readonly transformRequest?: RequestTransform
}
export type RequestTransform = (request: Request) => Effect.Effect<Request, LLMError>
export interface Transport<Body, Prepared, Frame> {
readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
@@ -690,4 +690,33 @@ describe("OpenAI Chat route", () => {
expect(events.map((event) => event.type)).toEqual(["step-start"])
}),
)
it.effect("transforms the serialized HTTP request before dispatch", () =>
Effect.gen(function* () {
const llm = yield* LLMClient.Service
const seen: string[] = []
yield* llm
.withRequestTransform((request) =>
Effect.sync(() => {
seen.push(request.url)
const headers = new Headers(request.headers)
headers.set("x-hook", "enabled")
return new Request(request, { headers })
}),
)
.stream(request)
.pipe(Stream.runDrain)
expect(seen).toEqual(["https://api.openai.test/v1/chat/completions"])
}).pipe(
Effect.provide(
dynamicResponse((input) => {
expect(input.request.headers["x-hook"]).toBe("enabled")
return Effect.succeed(
input.respond(sseEvents(deltaChunk({}, "stop")), { headers: { "content-type": "text/event-stream" } }),
)
}),
),
),
)
})
+25 -8
View File
@@ -1,5 +1,6 @@
export * as AISDK from "./aisdk"
import { AsyncLocalStorage } from "node:async_hooks"
import { makeLocationNode } from "./effect/app-node"
import type {
JSONSchema7,
@@ -29,7 +30,7 @@ import {
type ToolDefinition,
type UsageInput,
} from "@opencode-ai/ai"
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
import { Auth, Endpoint, type AnyRoute, type TransportRuntime } from "@opencode-ai/ai/route"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider"
@@ -39,6 +40,7 @@ type SDK = any
type UserContent = Extract<LanguageModelV3Message, { role: "user" }>["content"]
type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["content"]
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
const requestTransform = new AsyncLocalStorage<TransportRuntime["transformRequest"]>()
export interface SDKEvent {
readonly model: ModelV2.Info
@@ -149,10 +151,20 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
}
}
const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, {
...opts,
timeout: false,
})
const request = new Request(input as Request, opts)
const transform = requestTransform.getStore()
const transformed = transform ? await Effect.runPromise(transform(request)) : request
const upstream = typeof customFetch === "function" ? customFetch : fetch
const res = transform
? await upstream(transformed.url, {
...opts,
method: transformed.method,
headers: transformed.headers,
body: transformed === request || transformed.body === null ? opts.body : await transformed.clone().text(),
signal: transformed.signal,
timeout: false,
})
: await upstream(input, { ...opts, timeout: false })
if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
}
@@ -341,7 +353,8 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
with: () => route,
model: (input) => Model.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
prepareTransport: (body) => Effect.succeed(body),
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
streamPrepared: (prepared, _request, runtime) =>
streamLanguage(language, prepared as LanguageModelV3CallOptions, runtime.transformRequest),
}
return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route })
}
@@ -532,13 +545,17 @@ function providerOptions(input: LLMRequest["providerOptions"]): SharedV3Provider
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
}
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) {
function streamLanguage(
language: LanguageModelV3,
options: LanguageModelV3CallOptions,
transform: TransportRuntime["transformRequest"],
) {
const state = { step: 0, toolNames: {} as Record<string, string> }
return Stream.concat(
Stream.make(LLMEvent.stepStart({ index: state.step })),
Stream.unwrap(
Effect.tryPromise({
try: () => language.doStream(options),
try: () => requestTransform.run(transform, () => language.doStream(options)),
catch: (error) => llmError("doStream", error),
}).pipe(
Effect.map((result) =>
+5 -1
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 })
}),
)
+10 -7
View File
@@ -1,15 +1,18 @@
export * as SessionCompaction from "./compaction"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/ai"
import { LLM, LLMClient, LLMEvent, Message, type Model } from "@opencode-ai/ai"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Config } from "../config"
import { EventV2 } from "../event"
import { PluginHooks } from "../plugin/hooks"
import { makeLocationNode } from "../effect/app-node"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
import type { SessionMessage } from "./message"
import { SessionModelHeaders } from "./model-headers"
import { SessionRequestHook } from "./request-hook"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { toSessionError } from "./to-session-error"
@@ -59,11 +62,10 @@ type Settings = {
type Dependencies = {
readonly events: EventV2.Interface
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
}
readonly llm: LLMClientShape
readonly models: SessionRunnerModel.Interface
readonly config: Settings
readonly hooks: PluginHooks.Interface
}
export type AutoInput = {
@@ -240,7 +242,7 @@ const make = (dependencies: Dependencies) => {
const chunks: string[] = []
let failure: SessionError.Error | undefined
yield* dependencies.llm
yield* SessionRequestHook.client(dependencies.llm, dependencies.hooks, plan.session.id)
.stream(
LLM.request({
model: plan.model,
@@ -372,12 +374,13 @@ export const layer = Layer.effect(
const llm = yield* LLMClient.Service
const config = yield* Config.Service
const models = yield* SessionRunnerModel.Service
return make({ events, llm, models, config: settings(yield* config.entries()) })
const hooks = yield* PluginHooks.Service
return make({ events, llm, models, config: settings(yield* config.entries()), hooks })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node],
deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, PluginHooks.node],
})
+13
View File
@@ -0,0 +1,13 @@
export * as SessionRequestHook from "./request-hook"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import { Effect } from "effect"
import { PluginHooks } from "../plugin/hooks"
import { SessionSchema } from "./schema"
export const client = (llm: LLMClientShape, hooks: PluginHooks.Interface, sessionID: SessionSchema.ID) =>
hooks.has("session", "request")
? llm.withRequestTransform((request) =>
hooks.trigger("session", "request", { sessionID, request }).pipe(Effect.map((event) => event.request)),
)
: llm
+5 -1
View File
@@ -8,6 +8,7 @@ import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
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"
@@ -15,6 +16,7 @@ import { SessionCompaction } from "../compaction"
import { SessionContext } from "../context"
import { SessionEvent } from "../event"
import { SessionPending } from "../pending"
import { SessionRequestHook } from "../request-hook"
import { SessionModelRequest } from "../model-request"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
@@ -65,6 +67,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.
@@ -157,7 +160,7 @@ 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 providerStream = SessionRequestHook.client(llm, hooks, session.id).stream(prepared.request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
@@ -506,5 +509,6 @@ export const node = makeLocationNode({
SessionTitle.node,
Snapshot.node,
Database.node,
PluginHooks.node,
],
})
+10 -7
View File
@@ -1,15 +1,18 @@
export * as SessionTitle from "./title"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import { LLM, LLMClient, LLMEvent, Message } from "@opencode-ai/ai"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { AgentV2 } from "../agent"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { PluginHooks } from "../plugin/hooks"
import { makeLocationNode } from "../effect/app-node"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
import { SessionModelHeaders } from "./model-headers"
import { SessionRequestHook } from "./request-hook"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
@@ -17,11 +20,10 @@ const MAX_LENGTH = 100
type Dependencies = {
readonly events: EventV2.Interface
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
}
readonly llm: LLMClientShape
readonly agents: AgentV2.Interface
readonly models: SessionRunnerModel.Interface
readonly hooks: PluginHooks.Interface
}
export interface Interface {
@@ -51,7 +53,7 @@ const make = (dependencies: Dependencies) => {
if (!resolved) return
const chunks: string[] = []
let failed = false
const streamed = yield* dependencies.llm
const streamed = yield* SessionRequestHook.client(dependencies.llm, dependencies.hooks, session.id)
.stream(
LLM.request({
model: resolved.model,
@@ -93,7 +95,8 @@ export const layer = Layer.effect(
const agents = yield* AgentV2.Service
const models = yield* SessionRunnerModel.Service
const database = yield* Database.Service
const title = make({ events, llm, agents, models })
const hooks = yield* PluginHooks.Service
const title = make({ events, llm, agents, models, hooks })
return Service.of({
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
})
@@ -103,5 +106,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node],
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, PluginHooks.node],
})
+59 -1
View File
@@ -5,7 +5,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { LLM, Message } from "@opencode-ai/ai"
import { LLMClient } from "@opencode-ai/ai/route"
import { expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(AISDK.locationLayer)
@@ -19,6 +19,64 @@ const model = (packageName: string, settings: Record<string, unknown> = {}) =>
limit: { context: 100, output: 20 },
})
it.effect("applies request transforms to AI SDK fetch calls", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let received: Request | undefined
const input = model("test-sdk")
Object.defineProperty(input, "settings", {
value: {
fetch: async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
received = new Request(input as Request, init)
return new Response()
},
},
})
yield* aisdk.hook.sdk((event) => {
event.sdk = {
languageModel: () => ({
doStream: async () => {
await event.options.fetch("https://provider.test/generate", { method: "POST", body: "{}" })
return {
stream: new ReadableStream({
start(controller) {
controller.enqueue({
type: "finish",
finishReason: { unified: "stop" },
usage: {
inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 0, text: 0, reasoning: 0 },
},
})
controller.close()
},
}),
}
},
}),
}
})
const resolved = yield* aisdk.model(input)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const body = yield* resolved.route.body.from(request)
const prepared = yield* resolved.route.prepareTransport(body, request)
yield* resolved.route
.streamPrepared(prepared, request, {
http: { execute: () => Effect.die("unused") },
transformRequest: (request) =>
Effect.sync(() => {
const headers = new Headers(request.headers)
headers.set("x-hook", "enabled")
return new Request(request, { headers })
}),
})
.pipe(Stream.runDrain)
expect(received?.url).toBe("https://provider.test/generate")
expect(received?.headers.get("x-hook")).toBe("enabled")
}),
)
it.effect("keys language models by package and flattened overlays", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
+20
View File
@@ -42,4 +42,24 @@ describe("PluginHooks", () => {
expect(event.messages).toEqual([Message.user("changed")])
}),
)
it.effect("allows session request hooks to replace the raw request", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
expect(hooks.has("session", "request")).toBe(false)
yield* hooks.register("session", "request", (event) =>
Effect.sync(() => {
event.request = new Request(event.request, { headers: { "x-hook": "enabled" } })
}),
)
expect(hooks.has("session", "request")).toBe(true)
const event = {
sessionID: Session.ID.make("ses_request_hook"),
request: new Request("https://example.com"),
}
expect(yield* hooks.trigger("session", "request", event)).toBe(event)
expect(event.request.headers.get("x-hook")).toBe("enabled")
}),
)
})
+5 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
@@ -40,14 +41,16 @@ const projects = Layer.succeed(
}),
)
let requests: LLMRequest[] = []
const client = Layer.mock(LLMClient.Service)({
const clientShape: 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"),
})
withRequestTransform: () => clientShape,
}
const client = Layer.mock(LLMClient.Service)(clientShape)
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const locations = Layer.effect(
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
@@ -31,14 +32,16 @@ const model = Model.make({
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
})
const client = Layer.mock(LLMClient.Service)({
const clientShape: LLMClientShape = {
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual summary" }))
},
generate: () => Effect.die("unused"),
})
withRequestTransform: () => clientShape,
}
const client = Layer.mock(LLMClient.Service)(clientShape)
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
+4 -5
View File
@@ -89,9 +89,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)
@@ -113,8 +111,9 @@ const client = Layer.succeed(
)
}) as unknown as LLMClientShape["stream"],
generate: () => Effect.die("unused"),
}),
)
withRequestTransform: () => clientValue,
})
const client = Layer.succeed(LLMClient.Service, clientValue)
const reply = {
stop: () => [
LLMEvent.stepStart({ index: 0 }),
+5 -2
View File
@@ -1,5 +1,6 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
import type { LLMClientShape } from "@opencode-ai/ai/route"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
@@ -29,14 +30,16 @@ const model = Model.make({
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
})
const client = Layer.mock(LLMClient.Service)({
const clientShape: LLMClientShape = {
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }))
},
generate: () => Effect.die("unused"),
})
withRequestTransform: () => clientShape,
}
const client = Layer.mock(LLMClient.Service)(clientShape)
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
})
+13
View File
@@ -92,6 +92,19 @@ yield *
)
```
The serialized provider request is also mutable before it is sent:
```ts
yield *
ctx.session.hook("request", (event) =>
Effect.sync(() => {
event.request = new Request(event.request, {
headers: new Headers([...event.request.headers, ["x-plugin", "enabled"]]),
})
}),
)
```
## Reloading A Domain
When data captured by a transform changes, reload the affected domain:
+6
View File
@@ -15,8 +15,14 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionRequest {
readonly sessionID: Session.ID
request: Request
}
export interface SessionHooks {
readonly context: SessionContext
readonly request: SessionRequest
}
export type SessionDomain = Pick<
+10
View File
@@ -94,6 +94,16 @@ await ctx.session.hook("context", (event) => {
})
```
The serialized provider request is also mutable before it is sent:
```ts
await ctx.session.hook("request", (event) => {
event.request = new Request(event.request, {
headers: new Headers([...event.request.headers, ["x-plugin", "enabled"]]),
})
})
```
Promise tools use plain object declarations with async executors:
```ts
@@ -15,8 +15,14 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionRequest {
readonly sessionID: Session.ID
request: Request
}
export interface SessionHooks {
readonly context: SessionContext
readonly request: SessionRequest
}
export type SessionDomain = Pick<SessionApi, "create" | "get" | "prompt" | "command" | "synthetic" | "interrupt"> & {