Explorar el Código

refactor(plugin): split session HTTP hooks (#40724)

Aiden Cline hace 3 días
padre
commit
be2f74d44a

+ 2 - 58
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 extends keyof SessionHooks>(
-          name: Name,
-          callback: (event: SessionHooks[Name]) => Promise<void> | void,
-        ): Promise<Registration>
-        function sessionHook(
-          ...registration: {
-            [Name in keyof SessionHooks]: [
-              name: Name,
-              callback: (event: SessionHooks[Name]) => Promise<void> | 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(

+ 8 - 8
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)
       }),
     )
 

+ 21 - 35
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<Response, HttpClientRequest.HttpClientRequest>()
-            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<SessionHttpHandler>(
-              (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) {

+ 21 - 78
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<PluginHooks.Domains["session"]["http"]["use"]>[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<SessionHttpHandler>(
-        (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" }))
-
-      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<void>()
-      const interrupted = yield* Deferred.make<void>()
-      const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[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<SessionHttpHandler>(
-        (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)
+      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(yield* Deferred.isDone(interrupted)).toBeTrue()
+      expect(request.request.url).toBe("https://provider.test/changed")
+      expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
     }),
   )
 

+ 3 - 17
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<T>(value: T | undefined): T {
 }
 
 const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
-  const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[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<SessionHttpHandler>(
-    (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", () => {

+ 28 - 12
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<string | undefined> = []
   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!" }],
+      })
     }),
   )
 })

+ 12 - 10
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<string, { description: string; input: JsonSchema.JsonSchema }>
 }
 
-export interface SessionHttp {
+export interface SessionHttpRequest {
   readonly sessionID: Session.ID
   readonly agent: Agent.ID
   readonly model: Model.Ref
-  readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
+  request: Request
 }
 
-export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
-
-export type SessionHttpMiddleware = (
-  request: Request,
-  next: SessionHttpHandler,
-) => Effect.Effect<Response, Error>
+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<

+ 11 - 9
packages/plugin/src/promise/session.ts

@@ -15,23 +15,25 @@ export interface SessionContext {
   tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
 }
 
-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<Response>
-
-export type SessionHttpMiddleware = (
-  request: Request,
-  next: SessionHttpHandler,
-) => Promise<Response> | 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<

+ 16 - 8
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" },
   })
 })
 ```