From 1e3d32167fb17868ba5cc47e85b0accd889d1c8e Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:31:14 +1000 Subject: [PATCH] fix(core): send unchanged hooked requests without copying the body (#58449) --- packages/core/src/session/model-request.ts | 25 +++++----- .../test/session-model-request-hooks.test.ts | 48 +++++++++++++++++++ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 4bb8e47bae5..3949837ec05 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -317,17 +317,20 @@ export const layer = Layer.effect( const http: StreamOptions["http"] = hasHttpHooks ? (req, handler) => Effect.gen(function* () { - const before = yield* hooks.trigger("session", "http.request", { - ...scope, - request: yield* HttpClientRequest.toWeb(req), - }) - 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 request = yield* HttpClientRequest.toWeb(req) + const before = yield* hooks.trigger("session", "http.request", { ...scope, request }) + // A hook that kept the request object cannot have replaced its body, so the original body is sent as is. + // Reading it back would hold a second full copy of the request, screenshots included, for the whole call. + const sent = + before.request === request + ? HttpClientRequest.setBody(HttpClientRequest.fromWeb(request), req.body) + : before.request.body + ? HttpClientRequest.bodyUint8Array( + HttpClientRequest.fromWeb(before.request), + new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())), + before.request.headers.get("content-type") ?? undefined, + ) + : HttpClientRequest.fromWeb(before.request) const res = yield* handler(sent) const after = yield* hooks.trigger("session", "http.response", { ...scope, diff --git a/packages/core/test/session-model-request-hooks.test.ts b/packages/core/test/session-model-request-hooks.test.ts index 8e761206634..7fb1fa22493 100644 --- a/packages/core/test/session-model-request-hooks.test.ts +++ b/packages/core/test/session-model-request-hooks.test.ts @@ -80,6 +80,54 @@ describe("SessionModelRequest HTTP hooks", () => { }).pipe(Effect.provideService(SessionModelTransport.Service, transport)), ) + it.effect("sends a kept request's original body and a replaced request's new body", () => + Effect.gen(function* () { + const hooks = yield* PluginHooks.Service + let replace = false + yield* hooks.register("session", "http.request", (event) => + Effect.sync(() => { + if (replace) { + event.request = new Request(event.request.url, { method: "POST", body: '{"replaced":true}' }) + return + } + event.request.headers.set("x-plugin", "kept") + }), + ) + const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer)) + const prepared = yield* requests.primary({ + session, + agent: Agent.ID.make("build"), + model, + system: [], + messages: [], + }) + const http = prepared.options.http + if (!http) throw new Error("Expected HTTP middleware") + const original = HttpClientRequest.post("https://example.test/v1/chat/completions").pipe( + HttpClientRequest.bodyText('{"messages":[]}', "application/json"), + ) + const send = () => { + const sent: HttpClientRequest.HttpClientRequest[] = [] + return http(original, (request) => + Effect.sync(() => { + sent.push(request) + return HttpClientResponse.fromWeb(request, new Response("{}", { status: 200 })) + }), + ).pipe(Effect.map(() => sent[0]!)) + } + + const kept = yield* send() + expect(kept.body).toBe(original.body) + expect(kept.headers["x-plugin"]).toBe("kept") + + replace = true + const replaced = yield* send() + expect(replaced.body._tag).toBe("Uint8Array") + if (replaced.body._tag !== "Uint8Array") return + expect(new TextDecoder().decode(replaced.body.body)).toBe('{"replaced":true}') + }).pipe(Effect.provideService(SessionModelTransport.Service, transport)), + ) + it.effect("offers the WebSocket executor alongside HTTP hooks and routes the WebSocket hooks", () => Effect.gen(function* () { const hooks = yield* PluginHooks.Service