diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts new file mode 100644 index 00000000000..1a17189cfa6 --- /dev/null +++ b/src/plugins/capability-provider-runtime.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { createEmptyPluginRegistry } from "./registry.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "./runtime.js"; + +const mocks = vi.hoisted(() => ({ + loadOpenClawPlugins: vi.fn(() => createEmptyPluginRegistry()), + loadPluginManifestRegistry: vi.fn(() => ({ plugins: [], diagnostics: [] })), + withBundledPluginAllowlistCompat: vi.fn(({ config }) => config), + withBundledPluginEnablementCompat: vi.fn(({ config }) => config), + withBundledPluginVitestCompat: vi.fn(({ config }) => config), +})); + +vi.mock("./loader.js", () => ({ + loadOpenClawPlugins: mocks.loadOpenClawPlugins, +})); + +vi.mock("./manifest-registry.js", () => ({ + loadPluginManifestRegistry: mocks.loadPluginManifestRegistry, +})); + +vi.mock("./bundled-compat.js", () => ({ + withBundledPluginAllowlistCompat: mocks.withBundledPluginAllowlistCompat, + withBundledPluginEnablementCompat: mocks.withBundledPluginEnablementCompat, + withBundledPluginVitestCompat: mocks.withBundledPluginVitestCompat, +})); + +let resolvePluginCapabilityProviders: typeof import("./capability-provider-runtime.js").resolvePluginCapabilityProviders; + +describe("resolvePluginCapabilityProviders", () => { + beforeEach(async () => { + vi.resetModules(); + resetPluginRuntimeStateForTest(); + mocks.loadOpenClawPlugins.mockReset(); + mocks.loadOpenClawPlugins.mockReturnValue(createEmptyPluginRegistry()); + mocks.loadPluginManifestRegistry.mockReset(); + mocks.loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] }); + mocks.withBundledPluginAllowlistCompat.mockReset(); + mocks.withBundledPluginAllowlistCompat.mockImplementation(({ config }) => config); + mocks.withBundledPluginEnablementCompat.mockReset(); + mocks.withBundledPluginEnablementCompat.mockImplementation(({ config }) => config); + mocks.withBundledPluginVitestCompat.mockReset(); + mocks.withBundledPluginVitestCompat.mockImplementation(({ config }) => config); + ({ resolvePluginCapabilityProviders } = await import("./capability-provider-runtime.js")); + }); + + it("uses the active registry when capability providers are already loaded", () => { + const active = createEmptyPluginRegistry(); + active.speechProviders.push({ + pluginId: "openai", + pluginName: "OpenAI", + source: "test", + provider: { + id: "openai", + label: "OpenAI", + isConfigured: () => true, + synthesize: async () => ({ + audioBuffer: Buffer.from("x"), + outputFormat: "mp3", + voiceCompatible: false, + fileExtension: ".mp3", + }), + }, + }); + setActivePluginRegistry(active); + + const providers = resolvePluginCapabilityProviders({ key: "speechProviders" }); + + expect(providers.map((provider) => provider.id)).toEqual(["openai"]); + expect(mocks.loadPluginManifestRegistry).not.toHaveBeenCalled(); + expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled(); + }); + + it.each([ + ["speechProviders", "speechProviders"], + ["mediaUnderstandingProviders", "mediaUnderstandingProviders"], + ["imageGenerationProviders", "imageGenerationProviders"], + ] as const)("applies bundled compat before fallback loading for %s", (key, contractKey) => { + const cfg = { plugins: { allow: ["custom-plugin"] } } as OpenClawConfig; + const allowlistCompat = { plugins: { allow: ["custom-plugin", "openai"] } }; + const enablementCompat = { + plugins: { + allow: ["custom-plugin", "openai"], + entries: { openai: { enabled: true } }, + }, + }; + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "openai", + origin: "bundled", + contracts: { [contractKey]: ["openai"] }, + }, + { + id: "custom-plugin", + origin: "workspace", + contracts: {}, + }, + ] as Array>, + diagnostics: [], + }); + mocks.withBundledPluginAllowlistCompat.mockReturnValue(allowlistCompat); + mocks.withBundledPluginEnablementCompat.mockReturnValue(enablementCompat); + mocks.withBundledPluginVitestCompat.mockReturnValue(enablementCompat); + + resolvePluginCapabilityProviders({ key, cfg }); + + expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledWith({ + config: cfg, + env: process.env, + }); + expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledWith({ + config: cfg, + pluginIds: ["openai"], + }); + expect(mocks.withBundledPluginEnablementCompat).toHaveBeenCalledWith({ + config: allowlistCompat, + pluginIds: ["openai"], + }); + expect(mocks.withBundledPluginVitestCompat).toHaveBeenCalledWith({ + config: enablementCompat, + pluginIds: ["openai"], + env: process.env, + }); + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith({ + config: enablementCompat, + }); + }); +}); diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index fcb527b8880..2c14e868956 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -1,5 +1,11 @@ import type { OpenClawConfig } from "../config/config.js"; +import { + withBundledPluginAllowlistCompat, + withBundledPluginEnablementCompat, + withBundledPluginVitestCompat, +} from "./bundled-compat.js"; import { loadOpenClawPlugins } from "./loader.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; import type { PluginRegistry } from "./registry.js"; import { getActivePluginRegistry } from "./runtime.js"; @@ -8,9 +14,56 @@ type CapabilityProviderRegistryKey = | "mediaUnderstandingProviders" | "imageGenerationProviders"; +type CapabilityContractKey = + | "speechProviders" + | "mediaUnderstandingProviders" + | "imageGenerationProviders"; + type CapabilityProviderForKey = PluginRegistry[K][number] extends { provider: infer T } ? T : never; +const CAPABILITY_CONTRACT_KEY: Record = { + speechProviders: "speechProviders", + mediaUnderstandingProviders: "mediaUnderstandingProviders", + imageGenerationProviders: "imageGenerationProviders", +}; + +function resolveBundledCapabilityCompatPluginIds(params: { + key: CapabilityProviderRegistryKey; + cfg?: OpenClawConfig; +}): string[] { + const contractKey = CAPABILITY_CONTRACT_KEY[params.key]; + return loadPluginManifestRegistry({ + config: params.cfg, + env: process.env, + }) + .plugins.filter( + (plugin) => plugin.origin === "bundled" && (plugin.contracts?.[contractKey]?.length ?? 0) > 0, + ) + .map((plugin) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); +} + +function resolveCapabilityProviderConfig(params: { + key: CapabilityProviderRegistryKey; + cfg?: OpenClawConfig; +}) { + const pluginIds = resolveBundledCapabilityCompatPluginIds(params); + const allowlistCompat = withBundledPluginAllowlistCompat({ + config: params.cfg, + pluginIds, + }); + const enablementCompat = withBundledPluginEnablementCompat({ + config: allowlistCompat, + pluginIds, + }); + return withBundledPluginVitestCompat({ + config: enablementCompat, + pluginIds, + env: process.env, + }); +} + export function resolvePluginCapabilityProviders(params: { key: K; cfg?: OpenClawConfig; @@ -20,7 +73,11 @@ export function resolvePluginCapabilityProviders 0; const registry = - shouldUseActive || !params.cfg ? active : loadOpenClawPlugins({ config: params.cfg }); + shouldUseActive || !params.cfg + ? active + : loadOpenClawPlugins({ + config: resolveCapabilityProviderConfig({ key: params.key, cfg: params.cfg }), + }); return (registry?.[params.key] ?? []).map( (entry) => entry.provider, ) as CapabilityProviderForKey[]; diff --git a/src/tts/provider-registry.test.ts b/src/tts/provider-registry.test.ts index 9af822dea92..69e41f3f6a8 100644 --- a/src/tts/provider-registry.test.ts +++ b/src/tts/provider-registry.test.ts @@ -83,7 +83,17 @@ describe("speech provider registry", () => { expect(listSpeechProviders(cfg).map((provider) => provider.id)).toEqual(["microsoft"]); expect(getSpeechProvider("edge", cfg)?.id).toBe("microsoft"); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith({ config: cfg }); + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith({ + config: { + plugins: { + entries: { + elevenlabs: { enabled: true }, + microsoft: { enabled: true }, + openai: { enabled: true }, + }, + }, + }, + }); }); it("returns no providers when neither plugins nor active registry provide speech support", () => {