From be2f74d44a4341c581f739e3bf4edcff6cdcf211 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:23:22 -0500 Subject: [PATCH] refactor(plugin): split session HTTP hooks (#40724) --- packages/core/src/plugin/promise.ts | 60 +---------- packages/core/src/plugin/provider/openai.ts | 16 +-- packages/core/src/session/model-request.ts | 56 ++++------- packages/core/test/plugin/promise.test.ts | 99 ++++--------------- .../core/test/plugin/provider-openai.test.ts | 20 +--- .../core/test/session-runner-recorded.test.ts | 40 +++++--- packages/plugin/src/effect/session.ts | 22 +++-- packages/plugin/src/promise/session.ts | 20 ++-- packages/www/content/docs/build/plugins.mdx | 24 +++-- 9 files changed, 122 insertions(+), 235 deletions(-) diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 4880b9e3c3b..d575637a886 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -2,7 +2,6 @@ export * as PluginPromise from "./promise" import { define } from "@opencode-ai/plugin/effect/plugin" import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin" -import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session" import type { Info } from "@opencode-ai/plugin/promise/tool" import { Agent } from "@opencode-ai/schema/agent" import { Integration } from "@opencode-ai/schema/integration" @@ -58,62 +57,6 @@ export function fromPromise(plugin: Plugin) { }), ) - function sessionHook( - name: Name, - callback: (event: SessionHooks[Name]) => Promise | void, - ): Promise - function sessionHook( - ...registration: { - [Name in keyof SessionHooks]: [ - name: Name, - callback: (event: SessionHooks[Name]) => Promise | void, - ] - }[keyof SessionHooks] - ) { - if (registration[0] !== "http") - return register( - host.session.hook(registration[0], (event) => - Effect.promise(() => Promise.resolve(registration[1](event))), - ), - ) - return register( - host.session.hook("http", (event) => { - const middlewares: SessionHttpMiddleware[] = [] - const output: SessionHttp = { - ...event, - use: (item) => { - middlewares.push(item) - }, - } - return Effect.promise(() => Promise.resolve(registration[1](output))).pipe( - Effect.flatMap(() => - Effect.forEach( - middlewares, - (item) => - event.use((input, next) => - Effect.tryPromise({ - try: (signal) => { - const inputSignal = AbortSignal.any([signal, input.signal]) - return Promise.resolve( - item(new Request(input, { signal: inputSignal }), (request) => { - const requestSignal = AbortSignal.any([signal, request.signal]) - return Effect.runPromiseWith( - context, - )(next(new Request(request, { signal: requestSignal })), { signal: requestSignal }) - }), - ) - }, - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ), - { discard: true }, - ), - ), - ) - }), - ) - } - const context2: Context = { app: host.app, options: host.options, @@ -322,7 +265,8 @@ export function fromPromise(plugin: Plugin) { ), }, session: { - hook: sessionHook, + hook: (name, callback) => + register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), create: (input) => run( host.session.create( diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 8a464a31415..54ce0f1a3c6 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -225,14 +225,14 @@ export const OpenAIPlugin = define({ }) } }) - yield* ctx.session.hook("http", (evt) => - evt.use((request, next) => { - if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request) - const url = new URL(request.url) - request.headers.set("originator", "opencode") - request.headers.set("session-id", evt.sessionID) - if (url.origin !== "https://api.openai.com") return next(request) - return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request)) + yield* ctx.session.hook("http.request", (evt) => + Effect.sync(() => { + if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return + const url = new URL(evt.request.url) + evt.request.headers.set("originator", "opencode") + evt.request.headers.set("session-id", evt.sessionID) + if (url.origin !== "https://api.openai.com") return + evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request) }), ) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 8107f547cdf..37dd134c4b0 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -2,7 +2,6 @@ export * as SessionModelRequest from "./model-request" import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai" import type { StreamOptions } from "@opencode-ai/ai/route" -import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session" import type { Content } from "@opencode-ai/schema/tool" import { SessionError } from "@opencode-ai/schema/session-error" import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect" @@ -230,44 +229,31 @@ export const layer = Layer.effect( const options: StreamOptions = { http: (request, handler) => Effect.gen(function* () { - let latest = request - const origins = new WeakMap() - const middlewares: SessionHttpMiddleware[] = [] - const web = yield* HttpClientRequest.toWeb(request) - yield* hooks.trigger("session", "http", { + const before = yield* hooks.trigger("session", "http.request", { sessionID: session.id, agent: agent.id, model: resolved.ref, - use: (item) => - Effect.sync(() => { - middlewares.push(item) - }), + request: yield* HttpClientRequest.toWeb(request), }) - const send = (input: Request) => - Effect.gen(function* () { - let sent = HttpClientRequest.fromWeb(input) - if (input.body) - sent = HttpClientRequest.bodyUint8Array( - sent, - new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())), - input.headers.get("content-type") ?? undefined, - ) - latest = sent - const response = yield* handler(sent) - const body = [204, 205, 304].includes(response.status) - ? null - : yield* Stream.toReadableStreamEffect(response.stream) - const output = new Response(body, { status: response.status, headers: response.headers }) - origins.set(output, sent) - return output - }) - const dispatch = middlewares.reduce( - (next, item) => (input: Request) => item(input, next), - send, - ) - const response = yield* dispatch(web) - const origin = origins.get(response) ?? latest - return HttpClientResponse.fromWeb(origin, response) + let sent = HttpClientRequest.fromWeb(before.request) + if (before.request.body) + sent = HttpClientRequest.bodyUint8Array( + sent, + new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())), + before.request.headers.get("content-type") ?? undefined, + ) + const response = yield* handler(sent) + const after = yield* hooks.trigger("session", "http.response", { + sessionID: session.id, + agent: agent.id, + model: resolved.ref, + request: before.request, + response: new Response( + [204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream), + { status: response.status, headers: response.headers }, + ), + }) + return HttpClientResponse.fromWeb(sent, after.response) }).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))), } if (promptCacheSnapshots) { diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 50d9f630f04..e5551e4340b 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Message, SystemPart } from "@opencode-ai/ai" -import { DateTime, Deferred, Effect, Fiber, Schema } from "effect" +import { DateTime, Effect, Schema } from "effect" import { Agent } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { Model } from "@opencode-ai/core/model" @@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending" import { Tool } from "@opencode-ai/core/tool" import { Provider } from "@opencode-ai/core/provider" import { define } from "@opencode-ai/plugin/promise/plugin" -import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session" +import type { SessionHooks } from "@opencode-ai/plugin/effect/session" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" import { host as testHost } from "./host" @@ -223,102 +223,45 @@ describe("fromPromise", () => { }), ) - it.effect("adapts promise session HTTP hooks", () => + it.effect("adapts promise session HTTP request and response hooks", () => Effect.gen(function* () { const plugin = yield* Plugin.Service const hooks = yield* PluginHooks.Service const host = yield* PluginHost.make(plugin) - const bodies: string[] = [] yield* PluginPromise.fromPromise( define({ id: "promise-session-http", setup: async (ctx) => { - await ctx.session.hook("http", (event) => { - event.use(async (request, next) => { - request.headers.set("x-hook", "promise") - await next(request) - const response = await next(request) - return new Response(`${await response.text()}-response`) - }) + await ctx.session.hook("http.request", (event) => { + event.request = new Request("https://provider.test/changed", event.request) + event.request.headers.set("x-hook", "promise") }) - await ctx.session.hook("http", (event) => { - event.use(async (request, next) => { - const response = await next(request) - return new Response(`${await response.text()}-outer`) + await ctx.session.hook("http.response", async (event) => { + event.response = new Response(`${await event.response.text()}-response`, { + status: event.response.status, }) }) }, }), ).effect(host) - const middlewares: Parameters[0][] = [] - const event: PluginHooks.Domains["session"]["http"] = { + const context = { sessionID: Session.ID.make("ses_promise_session_http"), agent: Agent.ID.make("build"), model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), - use: (item) => - Effect.sync(() => { - middlewares.push(item) - }), } - yield* hooks.trigger("session", "http", event) - const request = middlewares.reduce( - (next, item) => (input: Request) => item(input, next), - (input: Request) => - Effect.promise(() => input.text()).pipe( - Effect.tap((body) => Effect.sync(() => bodies.push(body))), - Effect.as(new Response(input.headers.get("x-hook") ?? "missing")), - ), - ) - const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" })) + const request = yield* hooks.trigger("session", "http.request", { + ...context, + request: new Request("https://provider.test", { method: "POST", body: "payload" }), + }) + const response = yield* hooks.trigger("session", "http.response", { + ...context, + request: request.request, + response: new Response(request.request.headers.get("x-hook") ?? "missing"), + }) - expect(bodies).toEqual(["payload", "payload"]) - expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer") - }), - ) - - it.effect("interrupts the Effect request through a promise session HTTP hook", () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - const hooks = yield* PluginHooks.Service - const host = yield* PluginHost.make(plugin) - yield* PluginPromise.fromPromise( - define({ - id: "promise-session-http-interrupt", - setup: async (ctx) => { - await ctx.session.hook("http", (event) => { - event.use((request, next) => next(request)) - }) - }, - }), - ).effect(host) - const started = yield* Deferred.make() - const interrupted = yield* Deferred.make() - const middlewares: Parameters[0][] = [] - const event: PluginHooks.Domains["session"]["http"] = { - sessionID: Session.ID.make("ses_promise_session_http_interrupt"), - agent: Agent.ID.make("build"), - model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), - use: (item) => - Effect.sync(() => { - middlewares.push(item) - }), - } - - yield* hooks.trigger("session", "http", event) - const request = middlewares.reduce( - (next, item) => (input: Request) => item(input, next), - () => - Deferred.succeed(started, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), - ), - ) - const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild) - yield* Deferred.await(started) - yield* Fiber.interrupt(fiber) - - expect(yield* Deferred.isDone(interrupted)).toBeTrue() + expect(request.request.url).toBe("https://provider.test/changed") + expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response") }), ) diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 239599e31db..c53eefdbd24 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -12,7 +12,6 @@ import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" import { Provider } from "@opencode-ai/core/provider" -import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -31,26 +30,13 @@ function required(value: T | undefined): T { } const http = Effect.fn(function* (providerID: Provider.ID, url: string) { - const middlewares: Parameters[0][] = [] - yield* (yield* PluginHooks.Service).trigger("session", "http", { + const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", { sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build"), model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }), - use: (item) => - Effect.sync(() => { - middlewares.push(item) - }), + request: new Request(url, { method: "POST", body: "{}" }), }) - const request = middlewares.reduce( - (next, item) => (input: Request) => item(input, next), - (input: Request) => { - const headers = new Headers(input.headers) - headers.set("x-seen-url", input.url) - return Effect.succeed(new Response(null, { headers })) - }, - ) - const response = yield* request(new Request(url, { method: "POST", body: "{}" })) - return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) } + return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) } }) describe("OpenAIPlugin", () => { diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 56bc52da1ac..3b188323178 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -255,6 +255,7 @@ describe("SessionRunnerLLM recorded", () => { describe("SessionModelRequest HTTP bridge", () => { const bodies: Uint8Array[] = [] const methods: string[] = [] + const headers: Array = [] const response = [ 'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}', 'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}', @@ -268,6 +269,7 @@ describe("SessionModelRequest HTTP bridge", () => { if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`) methods.push(request.method) bodies.push(request.body.body.slice()) + headers.push(request.headers["x-hook"]) return HttpClientResponse.fromWeb( request, new Response(response, { headers: { "content-type": "text/event-stream" } }), @@ -275,14 +277,16 @@ describe("SessionModelRequest HTTP bridge", () => { }), ), ) - const retryIt = testEffect( + const httpIt = testEffect( testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))), ) - retryIt.effect("lets an Effect plugin send the same POST Request twice", () => + httpIt.effect("runs Effect HTTP request and response hooks around one provider request", () => Effect.gen(function* () { bodies.length = 0 methods.length = 0 + headers.length = 0 + const seen: string[] = [] const agents = yield* Agent.Service const catalog = yield* Catalog.Service const hooks = yield* PluginHooks.Service @@ -297,13 +301,20 @@ describe("SessionModelRequest HTTP bridge", () => { catalog: catalogHost(catalog), session: { hook: (name, callback) => hooks.register("session", name, callback) }, }) - yield* pluginHost.session.hook("http", (event) => - event.use((request, next) => - Effect.gen(function* () { - yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text()))) - return yield* next(request) - }), - ), + yield* pluginHost.session.hook("http.request", (event) => + Effect.sync(() => { + seen.push("request") + event.request.headers.set("x-hook", "effect") + }), + ) + yield* pluginHost.session.hook("http.response", (event) => + Effect.gen(function* () { + seen.push(`response:${event.response.status}:${event.request.headers.get("x-hook")}`) + event.response = new Response( + (yield* Effect.promise(() => event.response.text())).replace("Hello!", "Hooked!"), + event.response, + ) + }), ) yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true }) const { db } = yield* Database.Service @@ -331,10 +342,15 @@ describe("SessionModelRequest HTTP bridge", () => { yield* session.resume(retrySessionID) - expect(methods).toEqual(["POST", "POST"]) - expect(bodies).toHaveLength(2) + expect(methods).toEqual(["POST"]) + expect(headers).toEqual(["effect"]) + expect(seen).toEqual(["request", "response:200:effect"]) + expect(bodies).toHaveLength(1) expect(bodies[0]?.byteLength).toBeGreaterThan(0) - expect(bodies[1]).toEqual(bodies[0]) + expect((yield* session.context(retrySessionID))[1]).toMatchObject({ + type: "assistant", + content: [{ type: "text", text: "Hooked!" }], + }) }), ) }) diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts index d00eba103a5..c4564c2cd61 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/session.ts @@ -3,7 +3,7 @@ import type { Message, SystemPart } from "@opencode-ai/ai" import type { Agent } from "@opencode-ai/schema/agent" import type { Model } from "@opencode-ai/schema/model" import type { Session } from "@opencode-ai/schema/session" -import type { Effect, JsonSchema } from "effect" +import type { JsonSchema } from "effect" import type { Hooks } from "./registration.js" export interface SessionContext { @@ -15,23 +15,25 @@ export interface SessionContext { tools: Record } -export interface SessionHttp { +export interface SessionHttpRequest { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref - readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect + request: Request } -export type SessionHttpHandler = (request: Request) => Effect.Effect - -export type SessionHttpMiddleware = ( - request: Request, - next: SessionHttpHandler, -) => Effect.Effect +export interface SessionHttpResponse { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + readonly request: Request + response: Response +} export interface SessionHooks { readonly context: SessionContext - readonly http: SessionHttp + readonly "http.request": SessionHttpRequest + readonly "http.response": SessionHttpResponse } export type SessionDomain = Pick< diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index 40866a75cd3..acc6d00ba39 100644 --- a/packages/plugin/src/promise/session.ts +++ b/packages/plugin/src/promise/session.ts @@ -15,23 +15,25 @@ export interface SessionContext { tools: Record } -export interface SessionHttp { +export interface SessionHttpRequest { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref - readonly use: (middleware: SessionHttpMiddleware) => void + request: Request } -export type SessionHttpHandler = (request: Request) => Promise - -export type SessionHttpMiddleware = ( - request: Request, - next: SessionHttpHandler, -) => Promise | Response +export interface SessionHttpResponse { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + readonly request: Request + response: Response +} export interface SessionHooks { readonly context: SessionContext - readonly http: SessionHttp + readonly "http.request": SessionHttpRequest + readonly "http.response": SessionHttpResponse } export type SessionDomain = Pick< diff --git a/packages/www/content/docs/build/plugins.mdx b/packages/www/content/docs/build/plugins.mdx index 1b109bd2cf6..100791cecd7 100644 --- a/packages/www/content/docs/build/plugins.mdx +++ b/packages/www/content/docs/build/plugins.mdx @@ -246,19 +246,27 @@ Runtime hooks intercept live operations: | `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` | | `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` | | `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch | -| `ctx.session.hook("http", callback)` | `use`, registering request and response handling | +| `ctx.session.hook("http.request", callback)` | `request`, immediately before provider dispatch | +| `ctx.session.hook("http.response", callback)` | `response`, immediately after the provider responds | | `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes | | `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure | -HTTP hooks can modify requests, inspect responses, retry, or return a -response without calling the provider. It applies to native models; AI SDK -models do not currently pass through this hook. +HTTP hooks can modify requests and responses. They apply to native models; AI +SDK models do not currently pass through these hooks. Request and response +bodies are one-shot streams. Use `clone()` when you intentionally need a +separate reader, but be aware that its slower branch may buffer data. To inspect +or modify chunks while preserving streaming, replace the body with one piped +through a `TransformStream`. ```ts -await ctx.session.hook("http", (event) => { - event.use((request, next) => { - request.headers.set("x-session-id", event.sessionID) - return next(request) +await ctx.session.hook("http.request", (event) => { + event.request.headers.set("x-session-id", event.sessionID) +}) + +await ctx.session.hook("http.response", (event) => { + event.response = new Response(event.response.body, { + status: event.response.status, + headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "enabled" }, }) }) ```