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 <clawdbot@apilab.us>
This commit is contained in:
Conan-Scott
2026-05-03 22:22:34 +10:00
committed by GitHub
parent 4fff25438c
commit 8ebf86cdff
3 changed files with 77 additions and 4 deletions

View File

@@ -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

View File

@@ -156,7 +156,8 @@ function collectActiveRegistryLookups() {
Boolean(
options &&
typeof options === "object" &&
Object.hasOwn(options as Record<string, unknown>, "onlyPluginIds"),
Object.hasOwn(options as Record<string, unknown>, "onlyPluginIds") &&
!Object.hasOwn(options as Record<string, unknown>, "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({

View File

@@ -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<K extends CapabilityProviderRegistryKey>(
loadOptions: PluginLoadOptions;
requested?: Set<string>;
}): 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)