From 52267a6b758cfc5add6fa989c6881ca4e0a11b4d Mon Sep 17 00:00:00 2001 From: Jamil Zakirov Date: Sat, 25 Apr 2026 07:37:35 +0300 Subject: [PATCH] fix(auto-reply): run message_sending before inbound delivery Run inbound auto-reply delivery through message_sending hooks before sending replies. Co-authored-by: Jamil Zakirov <15848838+jzakirov@users.noreply.github.com> --- CHANGELOG.md | 1 + .../telegram/src/bot-message-dispatch.test.ts | 3 + .../telegram/src/bot-message-dispatch.ts | 1 + .../bot-native-commands.session-meta.test.ts | 3 + .../telegram/src/bot-native-commands.ts | 1 + src/auto-reply/dispatch.test.ts | 117 +++++++++++++++++- src/auto-reply/dispatch.ts | 81 +++++++++++- src/auto-reply/reply/before-deliver.test.ts | 68 ++++++++++ src/auto-reply/reply/reply-dispatcher.ts | 24 +++- .../reply/reply-dispatcher.types.ts | 1 + .../capability-provider-runtime.test.ts | 47 ++++++- src/plugins/capability-provider-runtime.ts | 7 +- 12 files changed, 346 insertions(+), 8 deletions(-) create mode 100644 src/auto-reply/reply/before-deliver.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 42105b9aebc..e6c4d5285e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -239,6 +239,7 @@ Docs: https://docs.openclaw.ai - Heartbeat: include async exec completion details in heartbeat prompts so command-finished notifications relay the actual output. (#71213) Thanks @GodsBoy. - Memory search: apply session visibility and agent-to-agent policy to session transcript hits, and keep `corpus=sessions` ranking scoped to session collections before result limiting. (#70761) Thanks @nefainl. - Agents/sessions: stop session write-lock timeouts from entering model failover, so local lock contention surfaces directly instead of cascading across providers. (#68700) Thanks @MonkeyLeeT. +- Auto-reply: run inbound reply delivery through `message_sending` hooks so plugins can transform or cancel generated replies before they are sent. (#70118) Thanks @jzakirov. ## 2026.4.23 diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index ba0b7e2f09d..9b060a74c43 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -427,6 +427,9 @@ describe("dispatchTelegramMessage draft streaming", () => { ); expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledWith( expect.objectContaining({ + dispatcherOptions: expect.objectContaining({ + beforeDeliver: expect.any(Function), + }), replyOptions: expect.objectContaining({ disableBlockStreaming: true, }), diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 09ae28434fe..ceb9ce507b1 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -761,6 +761,7 @@ export const dispatchTelegramMessage = async ({ cfg, dispatcherOptions: { ...replyPipeline, + beforeDeliver: async (payload) => payload, deliver: async (payload, info) => { if (isDispatchSuperseded()) { return; diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index c2d33a93719..801782d4f8b 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -449,6 +449,9 @@ describe("registerTelegramNativeCommands — session metadata", () => { await runPromise; expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); + expect( + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0].dispatcherOptions, + ).toEqual(expect.objectContaining({ beforeDeliver: expect.any(Function) })); }); it("does not inject approval buttons for native command replies once the monitor owns approvals", async () => { diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 0465e0a0d1f..98fb3f5504f 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -940,6 +940,7 @@ export const registerTelegramNativeCommands = ({ cfg: executionCfg, dispatcherOptions: { ...replyPipeline, + beforeDeliver: async (payload) => payload, deliver: async (payload, _info) => { if ( shouldSuppressLocalTelegramExecApprovalPrompt({ diff --git a/src/auto-reply/dispatch.test.ts b/src/auto-reply/dispatch.test.ts index ef1f5bb0d99..484f2a640a9 100644 --- a/src/auto-reply/dispatch.test.ts +++ b/src/auto-reply/dispatch.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { ReplyDispatcher } from "./reply/reply-dispatcher.js"; import { buildTestCtx } from "./reply/test-ctx.js"; @@ -6,12 +6,19 @@ import { buildTestCtx } from "./reply/test-ctx.js"; type DispatchReplyFromConfigFn = typeof import("./reply/dispatch-from-config.js").dispatchReplyFromConfig; type FinalizeInboundContextFn = typeof import("./reply/inbound-context.js").finalizeInboundContext; +type DeriveInboundMessageHookContextFn = + typeof import("../hooks/message-hook-mappers.js").deriveInboundMessageHookContext; +type GetGlobalHookRunnerFn = typeof import("../plugins/hook-runner-global.js").getGlobalHookRunner; +type CreateReplyDispatcherFn = typeof import("./reply/reply-dispatcher.js").createReplyDispatcher; type CreateReplyDispatcherWithTypingFn = typeof import("./reply/reply-dispatcher.js").createReplyDispatcherWithTyping; const hoisted = vi.hoisted(() => ({ dispatchReplyFromConfigMock: vi.fn(), finalizeInboundContextMock: vi.fn((ctx: unknown, _opts?: unknown) => ctx), + deriveInboundMessageHookContextMock: vi.fn(), + getGlobalHookRunnerMock: vi.fn(), + createReplyDispatcherMock: vi.fn(), createReplyDispatcherWithTypingMock: vi.fn(), })); @@ -25,12 +32,33 @@ vi.mock("./reply/inbound-context.js", () => ({ hoisted.finalizeInboundContextMock(...args), })); +vi.mock("../hooks/message-hook-mappers.js", () => ({ + deriveInboundMessageHookContext: (...args: Parameters) => + hoisted.deriveInboundMessageHookContextMock(...args), + toPluginMessageContext: (canonical: { + channelId?: string; + accountId?: string; + conversationId?: string; + }) => ({ + channelId: canonical.channelId, + accountId: canonical.accountId, + conversationId: canonical.conversationId, + }), +})); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: (...args: Parameters) => + hoisted.getGlobalHookRunnerMock(...args), +})); + vi.mock("./reply/reply-dispatcher.js", async () => { const actual = await vi.importActual( "./reply/reply-dispatcher.js", ); return { ...actual, + createReplyDispatcher: (...args: Parameters) => + hoisted.createReplyDispatcherMock(...args), createReplyDispatcherWithTyping: (...args: Parameters) => hoisted.createReplyDispatcherWithTypingMock(...args), }; @@ -38,6 +66,7 @@ vi.mock("./reply/reply-dispatcher.js", async () => { const { dispatchInboundMessage, + dispatchInboundMessageWithDispatcher, dispatchInboundMessageWithBufferedDispatcher, withReplyDispatcher, } = await import("./dispatch.js"); @@ -59,6 +88,22 @@ function createDispatcher(record: string[]): ReplyDispatcher { } describe("withReplyDispatcher", () => { + beforeEach(() => { + vi.clearAllMocks(); + hoisted.finalizeInboundContextMock.mockImplementation((ctx: unknown) => ctx); + hoisted.deriveInboundMessageHookContextMock.mockReturnValue({ + channelId: "threads", + accountId: "acct-1", + conversationId: "conv-1", + isGroup: false, + to: "thread:1", + }); + hoisted.getGlobalHookRunnerMock.mockReturnValue({ + hasHooks: vi.fn(() => false), + runMessageSending: vi.fn(async () => undefined), + }); + }); + it("dispatchInboundMessage owns dispatcher lifecycle", async () => { const order: string[] = []; const dispatcher = { @@ -168,6 +213,76 @@ describe("withReplyDispatcher", () => { expect(typing.markDispatchIdle).toHaveBeenCalled(); }); + it("runs message_sending hooks before inbound dispatcher delivery", async () => { + const runMessageSending = vi.fn(async () => ({ content: "sanitized reply" })); + hoisted.getGlobalHookRunnerMock.mockReturnValue({ + hasHooks: vi.fn((hookName?: string) => hookName === "message_sending"), + runMessageSending, + }); + hoisted.createReplyDispatcherMock.mockReturnValueOnce(createDispatcher([])); + hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ text: "ok" }); + + await dispatchInboundMessageWithDispatcher({ + ctx: buildTestCtx({ + From: "whatsapp:+15551234567", + To: "whatsapp:+15557654321", + OriginatingTo: "whatsapp:+15551234567", + }), + cfg: {} as OpenClawConfig, + dispatcherOptions: { + deliver: async () => undefined, + }, + replyResolver: async () => ({ text: "ok" }), + }); + + const dispatcherOptions = hoisted.createReplyDispatcherMock.mock.calls[0]?.[0]; + expect(dispatcherOptions?.beforeDeliver).toEqual(expect.any(Function)); + + const payload = await dispatcherOptions.beforeDeliver( + { text: "original reply" }, + { kind: "final" }, + ); + + expect(payload).toEqual({ text: "sanitized reply" }); + expect(runMessageSending).toHaveBeenCalledWith( + { content: "original reply", to: "whatsapp:+15551234567" }, + { + channelId: "threads", + accountId: "acct-1", + conversationId: "conv-1", + }, + ); + }); + + it("reconciles queuedFinal and counts after dispatcher-side cancellation", async () => { + const dispatcher = { + sendToolResult: () => true, + sendBlockReply: () => true, + sendFinalReply: () => true, + getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }), + getCancelledCounts: () => ({ tool: 0, block: 0, final: 1 }), + getFailedCounts: () => ({ tool: 0, block: 0, final: 0 }), + markComplete: () => undefined, + waitForIdle: async () => undefined, + } satisfies ReplyDispatcher; + hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ + queuedFinal: true, + counts: { tool: 0, block: 0, final: 1 }, + }); + + const result = await dispatchInboundMessage({ + ctx: buildTestCtx(), + cfg: {} as OpenClawConfig, + dispatcher, + replyResolver: async () => ({ text: "ok" }), + }); + + expect(result).toEqual({ + queuedFinal: false, + counts: { tool: 0, block: 0, final: 0 }, + }); + }); + it("uses CommandTargetSessionKey for silent-reply policy on native command turns", async () => { hoisted.createReplyDispatcherWithTypingMock.mockReturnValueOnce({ dispatcher: createDispatcher([]), diff --git a/src/auto-reply/dispatch.ts b/src/auto-reply/dispatch.ts index b7bd73c0eb3..6b9a72e523e 100644 --- a/src/auto-reply/dispatch.ts +++ b/src/auto-reply/dispatch.ts @@ -1,5 +1,10 @@ import { normalizeChatType } from "../channels/chat-type.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + deriveInboundMessageHookContext, + toPluginMessageContext, +} from "../hooks/message-hook-mappers.js"; +import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import type { SilentReplyConversationType } from "../shared/silent-reply-policy.js"; import { withReplyDispatcher } from "./dispatch-dispatcher.js"; import { dispatchReplyFromConfig } from "./reply/dispatch-from-config.js"; @@ -9,12 +14,13 @@ import { finalizeInboundContext } from "./reply/inbound-context.js"; import { createReplyDispatcher, createReplyDispatcherWithTyping, + type ReplyDispatchBeforeDeliver, type ReplyDispatcherOptions, type ReplyDispatcherWithTypingOptions, } from "./reply/reply-dispatcher.js"; import type { ReplyDispatcher } from "./reply/reply-dispatcher.types.js"; import type { FinalizedMsgContext, MsgContext } from "./templating.js"; -import type { GetReplyOptions } from "./types.js"; +import type { GetReplyOptions, ReplyPayload } from "./types.js"; function resolveDispatcherSilentReplyContext( ctx: MsgContext | FinalizedMsgContext, @@ -44,9 +50,74 @@ function resolveDispatcherSilentReplyContext( }; } +function resolveInboundReplyHookTarget( + finalized: FinalizedMsgContext, + hookCtx: ReturnType, +): string { + if (typeof finalized.OriginatingTo === "string" && finalized.OriginatingTo.trim()) { + return finalized.OriginatingTo; + } + if (hookCtx.isGroup) { + return hookCtx.conversationId ?? hookCtx.to ?? hookCtx.from; + } + return hookCtx.from || hookCtx.conversationId || hookCtx.to || ""; +} + +function buildMessageSendingBeforeDeliver( + ctx: MsgContext | FinalizedMsgContext, +): ReplyDispatchBeforeDeliver | undefined { + const hookRunner = getGlobalHookRunner(); + if (!hookRunner?.hasHooks("message_sending")) { + return undefined; + } + + const finalized = finalizeInboundContext(ctx); + const hookCtx = deriveInboundMessageHookContext(finalized); + const replyTarget = resolveInboundReplyHookTarget(finalized, hookCtx); + + return async (payload: ReplyPayload): Promise => { + if (!payload.text) { + return payload; + } + + const result = await hookRunner.runMessageSending( + { content: payload.text, to: replyTarget }, + toPluginMessageContext(hookCtx), + ); + + if (result?.cancel) { + return null; + } + if (result?.content != null) { + return { ...payload, text: result.content }; + } + return payload; + }; +} + export type DispatchInboundResult = DispatchFromConfigResult; export { withReplyDispatcher } from "./dispatch-dispatcher.js"; +function finalizeDispatchResult( + result: DispatchFromConfigResult, + dispatcher: ReplyDispatcher, +): DispatchFromConfigResult { + const cancelledCounts = dispatcher.getCancelledCounts?.(); + if (!cancelledCounts) { + return result; + } + + const counts = { + tool: Math.max(0, result.counts.tool - cancelledCounts.tool), + block: Math.max(0, result.counts.block - cancelledCounts.block), + final: Math.max(0, result.counts.final - cancelledCounts.final), + }; + return { + queuedFinal: result.queuedFinal && counts.final > 0, + counts, + }; +} + export async function dispatchInboundMessage(params: { ctx: MsgContext | FinalizedMsgContext; cfg: OpenClawConfig; @@ -55,7 +126,7 @@ export async function dispatchInboundMessage(params: { replyResolver?: GetReplyFromConfig; }): Promise { const finalized = finalizeInboundContext(params.ctx); - return await withReplyDispatcher({ + const result = await withReplyDispatcher({ dispatcher: params.dispatcher, run: () => dispatchReplyFromConfig({ @@ -66,6 +137,7 @@ export async function dispatchInboundMessage(params: { replyResolver: params.replyResolver, }), }); + return finalizeDispatchResult(result, params.dispatcher); } export async function dispatchInboundMessageWithBufferedDispatcher(params: { @@ -76,9 +148,12 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: { replyResolver?: GetReplyFromConfig; }): Promise { const silentReplyContext = resolveDispatcherSilentReplyContext(params.ctx, params.cfg); + const beforeDeliver = + params.dispatcherOptions.beforeDeliver ?? buildMessageSendingBeforeDeliver(params.ctx); const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = createReplyDispatcherWithTyping({ ...params.dispatcherOptions, + beforeDeliver, silentReplyContext: params.dispatcherOptions.silentReplyContext ?? silentReplyContext, }); try { @@ -108,6 +183,8 @@ export async function dispatchInboundMessageWithDispatcher(params: { const silentReplyContext = resolveDispatcherSilentReplyContext(params.ctx, params.cfg); const dispatcher = createReplyDispatcher({ ...params.dispatcherOptions, + beforeDeliver: + params.dispatcherOptions.beforeDeliver ?? buildMessageSendingBeforeDeliver(params.ctx), silentReplyContext: params.dispatcherOptions.silentReplyContext ?? silentReplyContext, }); return await dispatchInboundMessage({ diff --git a/src/auto-reply/reply/before-deliver.test.ts b/src/auto-reply/reply/before-deliver.test.ts new file mode 100644 index 00000000000..02948a7df8d --- /dev/null +++ b/src/auto-reply/reply/before-deliver.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import type { ReplyPayload } from "../types.js"; +import { createReplyDispatcher } from "./reply-dispatcher.js"; + +describe("beforeDeliver in reply dispatcher", () => { + it("cancels delivery when beforeDeliver returns null", async () => { + const delivered: string[] = []; + + const dispatcher = createReplyDispatcher({ + deliver: async (payload) => { + delivered.push(payload.text ?? ""); + }, + beforeDeliver: async (payload: ReplyPayload) => { + if (payload.text?.includes("blocked")) { + return null; + } + return payload; + }, + }); + + dispatcher.sendFinalReply({ text: "blocked reply" }); + dispatcher.sendFinalReply({ text: "safe reply" }); + dispatcher.markComplete(); + await dispatcher.waitForIdle(); + + expect(delivered).toEqual(["safe reply"]); + expect(dispatcher.getQueuedCounts()).toEqual({ tool: 0, block: 0, final: 2 }); + expect(dispatcher.getCancelledCounts?.()).toEqual({ tool: 0, block: 0, final: 1 }); + }); + + it("allows modifying payload in beforeDeliver", async () => { + const delivered: string[] = []; + + const dispatcher = createReplyDispatcher({ + deliver: async (payload) => { + delivered.push(payload.text ?? ""); + }, + beforeDeliver: async (payload: ReplyPayload) => { + if (payload.text?.includes("error")) { + return { ...payload, text: "replaced" }; + } + return payload; + }, + }); + + dispatcher.sendFinalReply({ text: "some error occurred" }); + dispatcher.markComplete(); + await dispatcher.waitForIdle(); + + expect(delivered).toEqual(["replaced"]); + }); + + it("delivers normally without beforeDeliver", async () => { + const delivered: string[] = []; + + const dispatcher = createReplyDispatcher({ + deliver: async (payload) => { + delivered.push(payload.text ?? ""); + }, + }); + + dispatcher.sendFinalReply({ text: "plain reply" }); + dispatcher.markComplete(); + await dispatcher.waitForIdle(); + + expect(delivered).toEqual(["plain reply"]); + }); +}); diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index 0bee71b169c..9fad32e668e 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -31,6 +31,11 @@ type ReplyDispatchDeliverer = ( info: { kind: ReplyDispatchKind }, ) => Promise; +export type ReplyDispatchBeforeDeliver = ( + payload: ReplyPayload, + info: { kind: ReplyDispatchKind }, +) => Promise | ReplyPayload | null; + const DEFAULT_HUMAN_DELAY_MIN_MS = 800; const DEFAULT_HUMAN_DELAY_MAX_MS = 2500; const silentReplyLogger = createSubsystemLogger("silent-reply/dispatcher"); @@ -73,6 +78,7 @@ export type ReplyDispatcherOptions = { onSkip?: ReplyDispatchSkipHandler; /** Human-like delay between block replies for natural rhythm. */ humanDelay?: HumanDelayConfig; + beforeDeliver?: ReplyDispatchBeforeDeliver; }; export type ReplyDispatcherWithTypingOptions = Omit & { @@ -190,6 +196,11 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis block: 0, final: 0, }; + const cancelledCounts: Record = { + tool: 0, + block: 0, + final: 0, + }; // Register this dispatcher globally for gateway restart coordination. const { unregister } = registerDispatcher({ @@ -242,9 +253,15 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis await sleep(delayMs); } } - // Safe: deliver is called inside an async .then() callback, so even a synchronous - // throw becomes a rejection that flows through .catch()/.finally(), ensuring cleanup. - await options.deliver(normalized, { kind }); + let deliverPayload: ReplyPayload | null = normalized; + if (options.beforeDeliver) { + deliverPayload = await options.beforeDeliver(normalized, { kind }); + if (!deliverPayload) { + cancelledCounts[kind] += 1; + return; + } + } + await options.deliver(deliverPayload, { kind }); }) .catch((err) => { failedCounts[kind] += 1; @@ -294,6 +311,7 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis sendFinalReply: (payload) => enqueue("final", payload), waitForIdle: () => sendChain, getQueuedCounts: () => ({ ...queuedCounts }), + getCancelledCounts: () => ({ ...cancelledCounts }), getFailedCounts: () => ({ ...failedCounts }), markComplete, }; diff --git a/src/auto-reply/reply/reply-dispatcher.types.ts b/src/auto-reply/reply/reply-dispatcher.types.ts index 7a4a9766f19..cd482e258ab 100644 --- a/src/auto-reply/reply/reply-dispatcher.types.ts +++ b/src/auto-reply/reply/reply-dispatcher.types.ts @@ -8,6 +8,7 @@ export type ReplyDispatcher = { sendFinalReply: (payload: ReplyPayload) => boolean; waitForIdle: () => Promise; getQueuedCounts: () => Record; + getCancelledCounts?: () => Record; getFailedCounts: () => Record; markComplete: () => void; }; diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index e4bdc05169b..058b8f62366 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -228,7 +228,10 @@ describe("resolvePluginCapabilityProviders", () => { const providers = resolvePluginCapabilityProviders({ key: "speechProviders", - cfg: { messages: { tts: { provider: "edge" } } } as OpenClawConfig, + cfg: { + plugins: { entries: { microsoft: { enabled: true } } }, + messages: { tts: { provider: "edge" } }, + } as OpenClawConfig, }); expectResolvedCapabilityProviderIds(providers, ["microsoft"]); @@ -238,6 +241,48 @@ describe("resolvePluginCapabilityProviders", () => { }); }); + it("keeps active capability providers when cfg has no explicit plugin config", () => { + const active = createEmptyPluginRegistry(); + active.speechProviders.push({ + pluginId: "acme", + pluginName: "acme", + source: "test", + provider: { + id: "acme", + label: "acme", + isConfigured: () => true, + synthesize: async () => ({ + audioBuffer: Buffer.from("x"), + outputFormat: "mp3", + voiceCompatible: false, + fileExtension: ".mp3", + }), + }, + } as never); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "microsoft", + origin: "bundled", + contracts: { speechProviders: ["microsoft"] }, + }, + ] as never, + diagnostics: [], + }); + mocks.resolveRuntimePluginRegistry.mockReturnValue(active); + + const providers = resolvePluginCapabilityProviders({ + key: "speechProviders", + cfg: { messages: { tts: { provider: "acme" } } } as OpenClawConfig, + }); + + expectResolvedCapabilityProviderIds(providers, ["acme"]); + expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith(); + expect(mocks.resolveRuntimePluginRegistry).not.toHaveBeenCalledWith({ + config: expect.anything(), + }); + }); + it("merges active and allowlisted bundled capability providers when cfg is passed", () => { const active = createEmptyPluginRegistry(); active.speechProviders.push({ diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index 27fd2596d1a..56773ca120c 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -4,6 +4,7 @@ import { withBundledPluginEnablementCompat, withBundledPluginVitestCompat, } from "./bundled-compat.js"; +import { hasExplicitPluginConfig } from "./config-policy.js"; import { resolveRuntimePluginRegistry } from "./loader.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; import type { PluginRegistry } from "./registry-types.js"; @@ -158,7 +159,11 @@ export function resolvePluginCapabilityProviders[] { const activeRegistry = resolveRuntimePluginRegistry(); const activeProviders = activeRegistry?.[params.key] ?? []; - if (activeProviders.length > 0 && params.key !== "memoryEmbeddingProviders" && !params.cfg) { + if ( + activeProviders.length > 0 && + params.key !== "memoryEmbeddingProviders" && + !hasExplicitPluginConfig(params.cfg?.plugins) + ) { return activeProviders.map((entry) => entry.provider) as CapabilityProviderForKey[]; } const compatConfig = resolveCapabilityProviderConfig({ key: params.key, cfg: params.cfg });