From 8ebf86cdff9c981634dc2960fde5cd3dd82e8335 Mon Sep 17 00:00:00 2001 From: Conan-Scott Date: Sun, 3 May 2026 22:22:34 +1000 Subject: [PATCH] fix(plugins): preserve external capability provider fallback (#76536) * fix(plugins): preserve external capability provider fallback * docs: move changelog entry to avoid merge conflict --------- Co-authored-by: Clawdbot --- CHANGELOG.md | 2 + .../capability-provider-runtime.test.ts | 59 ++++++++++++++++++- src/plugins/capability-provider-runtime.ts | 20 ++++++- 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f18b458dd6..088be466158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,8 @@ Docs: https://docs.openclaw.ai - Memory-core: treat exhausted file watcher limits as non-fatal for builtin memory auto-sync while preserving fatal handling for unrelated disk-full errors. (#73357) Thanks @solodmd. - Providers/Ollama: restore catalog context-window forwarding as `num_ctx` for native `/api/chat` requests; fixes tool selection and context truncation regressions on models with catalog entries (qwen3, llama3, gemma3, …) when no explicit `params.num_ctx` was configured. Fixes #76117. (#76181) Thanks @openperf. +- Plugins/providers: preserve scoped cold-load fallback for enabled external manifest-contract capability providers missing from the startup registry, so providers such as Fish Audio can resolve on request without requiring `activation.onStartup` for correctness. (#76536) Thanks @Conan-Scott. + ## 2026.5.2 ### Highlights diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index 34cf7ff2cf1..212079409a7 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -156,7 +156,8 @@ function collectActiveRegistryLookups() { Boolean( options && typeof options === "object" && - Object.hasOwn(options as Record, "onlyPluginIds"), + Object.hasOwn(options as Record, "onlyPluginIds") && + !Object.hasOwn(options as Record, "activate"), ), ); } @@ -471,6 +472,62 @@ describe("resolvePluginCapabilityProviders", () => { expect(mocks.loadBundledCapabilityRuntimeRegistry).not.toHaveBeenCalled(); }); + it("cold-loads enabled external manifest-contract providers missing from startup registry", () => { + const loaded = createEmptyPluginRegistry(); + loaded.speechProviders.push({ + pluginId: "fish-audio", + pluginName: "Fish Audio", + source: "test", + provider: { + id: "fish-audio", + label: "Fish Audio", + isConfigured: () => true, + synthesize: async () => ({ kind: "audio", data: Buffer.from([]), mimeType: "audio/mpeg" }), + }, + } as never); + mocks.loadPluginRegistrySnapshot.mockReturnValue({ + plugins: [{ pluginId: "fish-audio", origin: "global", enabled: true }], + }); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "fish-audio", + origin: "global", + enabledByDefault: false, + contracts: { speechProviders: ["fish-audio"] }, + }, + ], + diagnostics: [], + }); + mocks.resolveRuntimePluginRegistry.mockImplementation((options?: unknown) => { + if ( + options && + typeof options === "object" && + (options as { activate?: unknown }).activate === false + ) { + return loaded; + } + return undefined; + }); + + const provider = resolvePluginCapabilityProvider({ + key: "speechProviders", + providerId: "fish-audio", + }); + + expect(provider?.id).toBe("fish-audio"); + expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({ + onlyPluginIds: ["fish-audio"], + }); + expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith( + expect.objectContaining({ + activate: false, + onlyPluginIds: ["fish-audio"], + }), + ); + expect(mocks.loadBundledCapabilityRuntimeRegistry).not.toHaveBeenCalled(); + }); + it("uses active non-speech capability providers even when cfg has explicit plugin entries", () => { const active = createEmptyPluginRegistry(); active.mediaUnderstandingProviders.push({ diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index 236cc384ad0..f902c51c18f 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -6,7 +6,11 @@ import { withBundledPluginEnablementCompat, withBundledPluginVitestCompat, } from "./bundled-compat.js"; -import { resolvePluginRegistryLoadCacheKey, type PluginLoadOptions } from "./loader.js"; +import { + resolvePluginRegistryLoadCacheKey, + resolveRuntimePluginRegistry, + type PluginLoadOptions, +} from "./loader.js"; import { hasManifestContractValue, isManifestPluginAvailableForControlPlane, @@ -414,13 +418,23 @@ function loadCapabilityProviderEntries( loadOptions: PluginLoadOptions; requested?: Set; }): PluginRegistry[K] { - const registry = getLoadedRuntimePluginRegistry({ + const loadedRegistry = getLoadedRuntimePluginRegistry({ env: params.loadOptions.env, loadOptions: params.loadOptions, workspaceDir: params.loadOptions.workspaceDir, requiredPluginIds: params.loadOptions.onlyPluginIds, }); - const entries = registry?.[params.key] ?? []; + const loadedEntries = loadedRegistry?.[params.key] ?? []; + const coldRegistry = loadedRegistry + ? undefined + : resolveRuntimePluginRegistry(params.loadOptions); + const coldEntries = coldRegistry?.[params.key] ?? []; + const entries = + loadedEntries.length > 0 && coldEntries.length > 0 + ? mergeCapabilityProviderEntries(loadedEntries, coldEntries) + : loadedEntries.length > 0 + ? loadedEntries + : coldEntries; const missingRequested = params.key === "speechProviders" && params.requested && params.requested.size > 0 ? new Set(params.requested)