mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:50:43 +00:00
fix(plugins): preserve requested speech fallback
This commit is contained in:
@@ -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<typeof createEmptyPluginRegistry> | undefined
|
||||
>(() => undefined),
|
||||
@@ -19,6 +31,7 @@ const mocks = vi.hoisted(() => ({
|
||||
loadPluginManifestRegistry: vi.fn<(params?: Record<string, unknown>) => 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({
|
||||
|
||||
@@ -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<K extends CapabilityProviderRegistryKey>(
|
||||
return [...merged.values(), ...unnamed];
|
||||
}
|
||||
|
||||
function mergeCapabilityProviderEntries<K extends CapabilityProviderRegistryKey>(
|
||||
left: PluginRegistry[K],
|
||||
right: PluginRegistry[K],
|
||||
): PluginRegistry[K] {
|
||||
const merged = new Map<string, PluginRegistry[K][number]>();
|
||||
const unnamed: Array<PluginRegistry[K][number]> = [];
|
||||
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<string>, value: unknown): void {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return;
|
||||
@@ -319,6 +344,58 @@ function filterLoadedProvidersForRequestedConfig<K extends CapabilityProviderReg
|
||||
}) as PluginRegistry[K];
|
||||
}
|
||||
|
||||
function resolveRequestedCapabilityCompatPluginIds(params: {
|
||||
key: CapabilityProviderRegistryKey;
|
||||
cfg?: OpenClawConfig;
|
||||
requested?: Set<string>;
|
||||
}): string[] | undefined {
|
||||
if (params.key !== "speechProviders" || !params.requested || params.requested.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const pluginIds = new Set<string>();
|
||||
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<K extends CapabilityProviderRegistryKey>(params: {
|
||||
key: K;
|
||||
pluginIds: string[];
|
||||
loadOptions: PluginLoadOptions;
|
||||
requested?: Set<string>;
|
||||
}): 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<K extends CapabilityProviderRegistryKey>(params: {
|
||||
key: K;
|
||||
providerId: string;
|
||||
@@ -360,8 +437,12 @@ export function resolvePluginCapabilityProvider<K extends CapabilityProviderRegi
|
||||
: "";
|
||||
let loadedProviders = cache?.get(cacheKey) as PluginRegistry[K] | undefined;
|
||||
if (!loadedProviders) {
|
||||
const registry = resolveRuntimePluginRegistry(loadOptions);
|
||||
loadedProviders = registry?.[params.key] ?? [];
|
||||
loadedProviders = loadCapabilityProviderEntries({
|
||||
key: params.key,
|
||||
pluginIds,
|
||||
loadOptions,
|
||||
requested: new Set([params.providerId.toLowerCase()]),
|
||||
});
|
||||
cache?.set(cacheKey, loadedProviders as CapabilityProviderEntries);
|
||||
}
|
||||
return findProviderById(loadedProviders, params.providerId);
|
||||
@@ -391,10 +472,21 @@ export function resolvePluginCapabilityProviders<K extends CapabilityProviderReg
|
||||
return activeProviders.map((entry) => entry.provider) as CapabilityProviderForKey<K>[];
|
||||
}
|
||||
}
|
||||
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<K extends CapabilityProviderReg
|
||||
: "";
|
||||
let loadedProviders = cache?.get(cacheKey) as PluginRegistry[K] | undefined;
|
||||
if (!loadedProviders) {
|
||||
const registry = resolveRuntimePluginRegistry(loadOptions);
|
||||
loadedProviders = registry?.[params.key] ?? [];
|
||||
loadedProviders = loadCapabilityProviderEntries({
|
||||
key: params.key,
|
||||
pluginIds,
|
||||
loadOptions,
|
||||
requested: requestedSpeechProviders,
|
||||
});
|
||||
cache?.set(cacheKey, loadedProviders as CapabilityProviderEntries);
|
||||
}
|
||||
if (params.key !== "memoryEmbeddingProviders") {
|
||||
|
||||
Reference in New Issue
Block a user