diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index d8768c57f34..f9206ea2b34 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -12,6 +12,18 @@ function createEmptyMockManifestRegistry(): MockManifestRegistry { } const mocks = vi.hoisted(() => ({ + createMockRegistry: () => ({ + plugins: [], + diagnostics: [], + memoryEmbeddingProviders: [], + speechProviders: [], + realtimeTranscriptionProviders: [], + realtimeVoiceProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + videoGenerationProviders: [], + musicGenerationProviders: [], + }), resolveRuntimePluginRegistry: vi.fn< (params?: unknown) => ReturnType | undefined >(() => undefined), @@ -19,6 +31,7 @@ const mocks = vi.hoisted(() => ({ loadPluginManifestRegistry: vi.fn<(params?: Record) => MockManifestRegistry>( () => createEmptyMockManifestRegistry(), ), + loadBundledCapabilityRuntimeRegistry: vi.fn(), loadPluginRegistrySnapshot: vi.fn(() => ({ plugins: [] })), withBundledPluginAllowlistCompat: vi.fn( ({ config, pluginIds }: { config?: OpenClawConfig; pluginIds: string[] }) => @@ -39,6 +52,10 @@ vi.mock("./loader.js", () => ({ resolvePluginRegistryLoadCacheKey: mocks.resolvePluginRegistryLoadCacheKey, })); +vi.mock("./bundled-capability-runtime.js", () => ({ + loadBundledCapabilityRuntimeRegistry: mocks.loadBundledCapabilityRuntimeRegistry, +})); + vi.mock("./manifest-registry-installed.js", () => ({ loadPluginManifestRegistryForInstalledIndex: mocks.loadPluginManifestRegistry, })); @@ -191,6 +208,8 @@ describe("resolvePluginCapabilityProviders", () => { mocks.loadPluginRegistrySnapshot.mockReturnValue({ plugins: [] }); mocks.loadPluginManifestRegistry.mockReset(); mocks.loadPluginManifestRegistry.mockReturnValue(createEmptyMockManifestRegistry()); + mocks.loadBundledCapabilityRuntimeRegistry.mockReset(); + mocks.loadBundledCapabilityRuntimeRegistry.mockImplementation(() => mocks.createMockRegistry()); mocks.withBundledPluginAllowlistCompat.mockClear(); mocks.withBundledPluginAllowlistCompat.mockImplementation( ({ config, pluginIds }: { config?: OpenClawConfig; pluginIds: string[] }) => @@ -490,6 +509,169 @@ describe("resolvePluginCapabilityProviders", () => { }); }); + it("uses bundled capability capture when runtime snapshot is empty for a requested speech provider", () => { + 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", + }), + }, + } as never); + const captured = createEmptyPluginRegistry(); + captured.speechProviders.push({ + pluginId: "google", + pluginName: "google", + source: "test", + provider: { + id: "google", + label: "google", + isConfigured: () => true, + synthesize: async () => ({ + audioBuffer: Buffer.from("x"), + outputFormat: "mp3", + voiceCompatible: false, + fileExtension: ".mp3", + }), + }, + } as never); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "google", + origin: "bundled", + contracts: { speechProviders: ["google"] }, + }, + { + id: "microsoft", + origin: "bundled", + contracts: { speechProviders: ["microsoft"] }, + }, + ] as never, + diagnostics: [], + }); + mocks.resolveRuntimePluginRegistry.mockImplementation((params?: unknown) => + params === undefined ? active : createEmptyPluginRegistry(), + ); + mocks.loadBundledCapabilityRuntimeRegistry.mockReturnValue(captured); + + const providers = resolvePluginCapabilityProviders({ + key: "speechProviders", + cfg: { + messages: { tts: { provider: "google" } }, + } as OpenClawConfig, + }); + + expectResolvedCapabilityProviderIds(providers, ["openai", "google"]); + expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({ + config: expect.anything(), + onlyPluginIds: ["google"], + activate: false, + installBundledRuntimeDeps: false, + }); + expect(mocks.loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledWith({ + pluginIds: ["google"], + env: process.env, + pluginSdkResolution: undefined, + }); + }); + + it("uses bundled capability capture when runtime snapshot misses a requested speech provider", () => { + 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", + }), + }, + } as never); + const loaded = createEmptyPluginRegistry(); + loaded.speechProviders.push({ + pluginId: "azure-speech", + pluginName: "azure-speech", + source: "test", + provider: { + id: "azure-speech", + label: "Azure Speech", + isConfigured: () => true, + synthesize: async () => ({ + audioBuffer: Buffer.from("x"), + outputFormat: "mp3", + voiceCompatible: false, + fileExtension: ".mp3", + }), + }, + } as never); + const captured = createEmptyPluginRegistry(); + captured.speechProviders.push({ + pluginId: "google", + pluginName: "google", + source: "test", + provider: { + id: "google", + label: "google", + isConfigured: () => true, + synthesize: async () => ({ + audioBuffer: Buffer.from("x"), + outputFormat: "mp3", + voiceCompatible: false, + fileExtension: ".mp3", + }), + }, + } as never); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "azure-speech", + origin: "bundled", + contracts: { speechProviders: ["azure-speech"] }, + }, + { + id: "google", + origin: "bundled", + contracts: { speechProviders: ["google"] }, + }, + ] as never, + diagnostics: [], + }); + mocks.resolveRuntimePluginRegistry.mockImplementation((params?: unknown) => + params === undefined ? active : loaded, + ); + mocks.loadBundledCapabilityRuntimeRegistry.mockReturnValue(captured); + + const providers = resolvePluginCapabilityProviders({ + key: "speechProviders", + cfg: { + messages: { tts: { provider: "google" } }, + } as OpenClawConfig, + }); + + expectResolvedCapabilityProviderIds(providers, ["openai", "google"]); + expect(mocks.loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledWith({ + pluginIds: ["google"], + env: process.env, + pluginSdkResolution: undefined, + }); + }); + it("does not merge unrelated bundled capability providers when cfg requests one provider", () => { const active = createEmptyPluginRegistry(); active.speechProviders.push({ diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index 07a63d4e5a5..58043af3762 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js"; import { withBundledPluginAllowlistCompat, withBundledPluginEnablementCompat, @@ -207,6 +208,30 @@ function mergeCapabilityProviders( return [...merged.values(), ...unnamed]; } +function mergeCapabilityProviderEntries( + left: PluginRegistry[K], + right: PluginRegistry[K], +): PluginRegistry[K] { + const merged = new Map(); + const unnamed: Array = []; + const addEntries = (entries: PluginRegistry[K]) => { + for (const entry of entries) { + const provider = entry.provider as { id?: string }; + if (!provider.id) { + unnamed.push(entry); + continue; + } + if (!merged.has(provider.id)) { + merged.set(provider.id, entry); + } + } + }; + + addEntries(left); + addEntries(right); + return [...merged.values(), ...unnamed] as PluginRegistry[K]; +} + function addObjectKeys(target: Set, value: unknown): void { if (typeof value !== "object" || value === null || Array.isArray(value)) { return; @@ -319,6 +344,58 @@ function filterLoadedProvidersForRequestedConfig; +}): string[] | undefined { + if (params.key !== "speechProviders" || !params.requested || params.requested.size === 0) { + return undefined; + } + const pluginIds = new Set(); + for (const providerId of params.requested) { + for (const pluginId of resolveBundledCapabilityCompatPluginIds({ + key: params.key, + cfg: params.cfg, + providerId, + })) { + pluginIds.add(pluginId); + } + } + return pluginIds.size > 0 + ? [...pluginIds].toSorted((left, right) => left.localeCompare(right)) + : undefined; +} + +function loadCapabilityProviderEntries(params: { + key: K; + pluginIds: string[]; + loadOptions: PluginLoadOptions; + requested?: Set; +}): PluginRegistry[K] { + const registry = resolveRuntimePluginRegistry(params.loadOptions); + const entries = registry?.[params.key] ?? []; + const missingRequested = + params.key === "speechProviders" && params.requested && params.requested.size > 0 + ? new Set(params.requested) + : undefined; + if (missingRequested) { + removeActiveProviderIds(missingRequested, entries); + } + if (entries.length > 0 && (!missingRequested || missingRequested.size === 0)) { + return entries; + } + if (params.pluginIds.length === 0) { + return entries; + } + const captured = loadBundledCapabilityRuntimeRegistry({ + pluginIds: params.pluginIds, + env: process.env, + pluginSdkResolution: params.loadOptions.pluginSdkResolution, + })[params.key] as PluginRegistry[K]; + return entries.length > 0 ? mergeCapabilityProviderEntries(entries, captured) : captured; +} + export function resolvePluginCapabilityProvider(params: { key: K; providerId: string; @@ -360,8 +437,12 @@ export function resolvePluginCapabilityProvider entry.provider) as CapabilityProviderForKey[]; } } - const pluginIds = resolveBundledCapabilityCompatPluginIds({ - key: params.key, - cfg: params.cfg, - }); + const requestedSpeechProviders = + missingRequestedSpeechProviders ?? + (activeProviders.length === 0 && params.key === "speechProviders" + ? collectRequestedSpeechProviderIds(params.cfg) + : undefined); + const pluginIds = + resolveRequestedCapabilityCompatPluginIds({ + key: params.key, + cfg: params.cfg, + requested: requestedSpeechProviders, + }) ?? + resolveBundledCapabilityCompatPluginIds({ + key: params.key, + cfg: params.cfg, + }); const compatConfig = resolveCapabilityProviderConfig({ key: params.key, cfg: params.cfg, @@ -411,8 +503,12 @@ export function resolvePluginCapabilityProviders