From 2566f44f6083e63d7991ca16aac26d474010fa6c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:12:57 -0300 Subject: [PATCH] refactor(chatCore): extrai runPluginOnResponseHook (#3501) (#4782) chatCore #3501: extract runPluginOnResponseHook. Integrated into release/v3.8.35. --- open-sse/handlers/chatCore.ts | 11 +-- .../handlers/chatCore/pluginOnResponse.ts | 34 +++++++++ tests/unit/chatcore-plugin-onresponse.test.ts | 73 +++++++++++++++++++ 3 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 open-sse/handlers/chatCore/pluginOnResponse.ts create mode 100644 tests/unit/chatcore-plugin-onresponse.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index e83d223b32..147ea94f2a 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -170,6 +170,7 @@ import { recordCompressionCacheStats } from "./chatCore/compressionCacheStats.ts import { writeCavemanOutputAnalytics } from "./chatCore/cavemanOutputAnalytics.ts"; import { scheduleQuotaShareConsumption } from "./chatCore/quotaShareConsumption.ts"; import { emitRequestGamificationEvent } from "./chatCore/gamificationEvent.ts"; +import { runPluginOnResponseHook } from "./chatCore/pluginOnResponse.ts"; import { appendNonStreamingSseTerminalSignal, type NonStreamingSseTerminalState, @@ -4205,15 +4206,7 @@ export async function handleChatCore({ await emitRequestGamificationEvent({ apiKeyId: apiKeyInfo?.id, model, provider }); // ── Plugin onResponse hook (fire-and-forget) ── - try { - const { runOnResponse } = await import("@/lib/plugins/hooks"); - runOnResponse( - { requestId: traceId, body, model, provider, apiKeyInfo, metadata: {} }, - { status: 200 } - ).catch(() => {}); - } catch (_) { - /* plugin onResponse optional */ - } + await runPluginOnResponseHook({ requestId: traceId, body, model, provider, apiKeyInfo }); return { success: true, diff --git a/open-sse/handlers/chatCore/pluginOnResponse.ts b/open-sse/handlers/chatCore/pluginOnResponse.ts new file mode 100644 index 0000000000..e5eb90cea3 --- /dev/null +++ b/open-sse/handlers/chatCore/pluginOnResponse.ts @@ -0,0 +1,34 @@ +/** + * chatCore plugin onResponse hook (Quality Gate v2 / Fase 9 — chatCore god-file decomposition, + * #3501). + * + * Extracted from handleChatCore's streaming finalization: runs the registered plugin `onResponse` + * hooks for a completed (status 200) response. Fire-and-forget and fail-open — the inner run is + * not awaited and both the dynamic import and the run swallow their own errors, so a misbehaving + * plugin never affects the response. Behaviour is byte-identical to the previous inline block. + */ + +export async function runPluginOnResponseHook(args: { + requestId: string; + body: unknown; + model: string | null | undefined; + provider: string | null | undefined; + apiKeyInfo: unknown; +}): Promise { + try { + const { runOnResponse } = await import("@/lib/plugins/hooks"); + runOnResponse( + { + requestId: args.requestId, + body: args.body, + model: args.model, + provider: args.provider, + apiKeyInfo: args.apiKeyInfo, + metadata: {}, + }, + { status: 200 } + ).catch(() => {}); + } catch (_) { + /* plugin onResponse optional */ + } +} diff --git a/tests/unit/chatcore-plugin-onresponse.test.ts b/tests/unit/chatcore-plugin-onresponse.test.ts new file mode 100644 index 0000000000..a7b733dd91 --- /dev/null +++ b/tests/unit/chatcore-plugin-onresponse.test.ts @@ -0,0 +1,73 @@ +// Characterization of runPluginOnResponseHook — the plugin onResponse hook extracted from +// handleChatCore's streaming finalization (chatCore god-file decomposition, #3501). Hooks are +// in-memory (no DB). Locks: a registered onResponse hook receives the request context + status-200 +// response, and the helper is fire-and-forget / fail-open (no registered hooks → no-op). +import { test, after } from "node:test"; +import assert from "node:assert/strict"; + +const { registerHook, unregisterHook } = await import("../../src/lib/plugins/hooks.ts"); +const { runPluginOnResponseHook } = await import( + "../../open-sse/handlers/chatCore/pluginOnResponse.ts" +); + +async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline && !pred()) { + await new Promise((r) => setTimeout(r, 10)); + } +} + +after(() => { + unregisterHook("onResponse", "test-onresponse-plugin"); +}); + +test("no registered hooks → resolves without throwing (no-op)", async () => { + await assert.doesNotReject( + runPluginOnResponseHook({ + requestId: "req-noop", + body: { messages: [] }, + model: "gpt-x", + provider: "openai", + apiKeyInfo: null, + }) + ); +}); + +test("registered onResponse hook receives the request context and status-200 response", async () => { + let captured: Record | undefined; + registerHook("onResponse", "test-onresponse-plugin", async (ctx: Record) => { + captured = ctx; + return {}; + }); + + await runPluginOnResponseHook({ + requestId: "req-42", + body: { messages: [{ role: "user", content: "hi" }] }, + model: "gpt-4o", + provider: "openai", + apiKeyInfo: { id: "key-1" }, + }); + + await waitFor(() => captured !== undefined); + assert.ok(captured, "expected the onResponse hook to be invoked"); + assert.equal(captured!.requestId, "req-42"); + assert.equal(captured!.model, "gpt-4o"); + assert.equal(captured!.provider, "openai"); + assert.deepEqual(captured!.response, { status: 200 }); +}); + +test("a throwing hook never rejects the caller (fail-open)", async () => { + registerHook("onResponse", "test-onresponse-plugin", async () => { + throw new Error("boom"); + }); + await assert.doesNotReject( + runPluginOnResponseHook({ + requestId: "req-throw", + body: {}, + model: "m", + provider: "p", + apiKeyInfo: null, + }) + ); + await new Promise((r) => setTimeout(r, 30)); +});