Plugins: share runtime registry resolution

This commit is contained in:
Gustavo Madeira Santana
2026-03-28 00:18:50 -04:00
parent f811ce5052
commit ee7f5825c8
11 changed files with 96 additions and 80 deletions

View File

@@ -1,26 +1,23 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => ({
loadOpenClawPlugins: vi.fn(),
getCompatibleActivePluginRegistry: vi.fn(),
resolveRuntimePluginRegistry: vi.fn(),
}));
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins: hoisted.loadOpenClawPlugins,
getCompatibleActivePluginRegistry: hoisted.getCompatibleActivePluginRegistry,
resolveRuntimePluginRegistry: hoisted.resolveRuntimePluginRegistry,
}));
describe("ensureRuntimePluginsLoaded", () => {
beforeEach(() => {
hoisted.loadOpenClawPlugins.mockReset();
hoisted.getCompatibleActivePluginRegistry.mockReset();
hoisted.getCompatibleActivePluginRegistry.mockReturnValue(undefined);
hoisted.resolveRuntimePluginRegistry.mockReset();
hoisted.resolveRuntimePluginRegistry.mockReturnValue(undefined);
vi.resetModules();
});
it("does not reactivate plugins when a process already has an active registry", async () => {
const { ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js");
hoisted.getCompatibleActivePluginRegistry.mockReturnValue({});
hoisted.resolveRuntimePluginRegistry.mockReturnValue({});
ensureRuntimePluginsLoaded({
config: {} as never,
@@ -28,10 +25,10 @@ describe("ensureRuntimePluginsLoaded", () => {
allowGatewaySubagentBinding: true,
});
expect(hoisted.loadOpenClawPlugins).not.toHaveBeenCalled();
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(1);
});
it("loads runtime plugins when no compatible active registry exists", async () => {
it("resolves runtime plugins through the shared runtime helper", async () => {
const { ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js");
ensureRuntimePluginsLoaded({
@@ -40,7 +37,7 @@ describe("ensureRuntimePluginsLoaded", () => {
allowGatewaySubagentBinding: true,
});
expect(hoisted.loadOpenClawPlugins).toHaveBeenCalledWith({
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: {} as never,
workspaceDir: "/tmp/workspace",
runtimeOptions: {
@@ -58,13 +55,13 @@ describe("ensureRuntimePluginsLoaded", () => {
allowGatewaySubagentBinding: true,
});
expect(hoisted.getCompatibleActivePluginRegistry).toHaveBeenCalledWith({
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: {} as never,
workspaceDir: "/tmp/workspace",
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
});
expect(hoisted.loadOpenClawPlugins).toHaveBeenCalledTimes(1);
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "../config/config.js";
import { getCompatibleActivePluginRegistry, loadOpenClawPlugins } from "../plugins/loader.js";
import { resolveRuntimePluginRegistry } from "../plugins/loader.js";
import { resolveUserPath } from "../utils.js";
export function ensureRuntimePluginsLoaded(params: {
@@ -20,8 +20,5 @@ export function ensureRuntimePluginsLoaded(params: {
}
: undefined,
};
if (getCompatibleActivePluginRegistry(loadOptions)) {
return;
}
loadOpenClawPlugins(loadOptions);
resolveRuntimePluginRegistry(loadOptions);
}

View File

@@ -2,16 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
const { loadOpenClawPluginsMock, getCompatibleActivePluginRegistryMock } = vi.hoisted(() => ({
loadOpenClawPluginsMock: vi.fn(() => createEmptyPluginRegistry()),
getCompatibleActivePluginRegistryMock: vi.fn<
const { resolveRuntimePluginRegistryMock } = vi.hoisted(() => ({
resolveRuntimePluginRegistryMock: vi.fn<
(params?: unknown) => ReturnType<typeof createEmptyPluginRegistry> | undefined
>(() => undefined),
}));
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins: loadOpenClawPluginsMock,
getCompatibleActivePluginRegistry: getCompatibleActivePluginRegistryMock,
resolveRuntimePluginRegistry: resolveRuntimePluginRegistryMock,
}));
let getImageGenerationProvider: typeof import("./provider-registry.js").getImageGenerationProvider;
@@ -19,10 +17,8 @@ let listImageGenerationProviders: typeof import("./provider-registry.js").listIm
describe("image-generation provider registry", () => {
afterEach(() => {
loadOpenClawPluginsMock.mockReset();
loadOpenClawPluginsMock.mockReturnValue(createEmptyPluginRegistry());
getCompatibleActivePluginRegistryMock.mockReset();
getCompatibleActivePluginRegistryMock.mockReturnValue(undefined);
resolveRuntimePluginRegistryMock.mockReset();
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
resetPluginRuntimeStateForTest();
});
@@ -34,7 +30,7 @@ describe("image-generation provider registry", () => {
it("does not load plugins when listing without config", () => {
expect(listImageGenerationProviders()).toEqual([]);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(undefined);
});
it("uses active plugin providers without loading from disk", () => {
@@ -56,12 +52,12 @@ describe("image-generation provider registry", () => {
},
});
setActivePluginRegistry(registry);
getCompatibleActivePluginRegistryMock.mockReturnValue(registry);
resolveRuntimePluginRegistryMock.mockReturnValue(registry);
const provider = getImageGenerationProvider("custom-image");
expect(provider?.id).toBe("custom-image");
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(undefined);
});
it("ignores prototype-like provider ids and aliases", () => {
@@ -101,7 +97,7 @@ describe("image-generation provider registry", () => {
},
);
setActivePluginRegistry(registry);
getCompatibleActivePluginRegistryMock.mockReturnValue(registry);
resolveRuntimePluginRegistryMock.mockReturnValue(registry);
expect(listImageGenerationProviders().map((provider) => provider.id)).toEqual(["safe-image"]);
expect(getImageGenerationProvider("__proto__")).toBeUndefined();

View File

@@ -13,8 +13,7 @@ function createEmptyMockManifestRegistry(): MockManifestRegistry {
}
const mocks = vi.hoisted(() => ({
loadOpenClawPlugins: vi.fn(() => createEmptyPluginRegistry()),
getCompatibleActivePluginRegistry: vi.fn<
resolveRuntimePluginRegistry: vi.fn<
(params?: unknown) => ReturnType<typeof createEmptyPluginRegistry> | undefined
>(() => undefined),
loadPluginManifestRegistry: vi.fn<() => MockManifestRegistry>(() =>
@@ -26,8 +25,7 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock("./loader.js", () => ({
loadOpenClawPlugins: mocks.loadOpenClawPlugins,
getCompatibleActivePluginRegistry: mocks.getCompatibleActivePluginRegistry,
resolveRuntimePluginRegistry: mocks.resolveRuntimePluginRegistry,
}));
vi.mock("./manifest-registry.js", () => ({
@@ -73,7 +71,7 @@ function expectBundledCompatLoadPath(params: {
pluginIds: ["openai"],
env: process.env,
});
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith({
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: params.enablementCompat,
});
}
@@ -112,10 +110,8 @@ describe("resolvePluginCapabilityProviders", () => {
beforeEach(async () => {
vi.resetModules();
resetPluginRuntimeStateForTest();
mocks.loadOpenClawPlugins.mockReset();
mocks.loadOpenClawPlugins.mockReturnValue(createEmptyPluginRegistry());
mocks.getCompatibleActivePluginRegistry.mockReset();
mocks.getCompatibleActivePluginRegistry.mockReturnValue(undefined);
mocks.resolveRuntimePluginRegistry.mockReset();
mocks.resolveRuntimePluginRegistry.mockReturnValue(undefined);
mocks.loadPluginManifestRegistry.mockReset();
mocks.loadPluginManifestRegistry.mockReturnValue(createEmptyMockManifestRegistry());
mocks.withBundledPluginAllowlistCompat.mockReset();
@@ -145,13 +141,13 @@ describe("resolvePluginCapabilityProviders", () => {
}),
},
});
mocks.getCompatibleActivePluginRegistry.mockReturnValue(active);
mocks.resolveRuntimePluginRegistry.mockReturnValue(active);
const providers = resolvePluginCapabilityProviders({ key: "speechProviders" });
expectResolvedCapabilityProviderIds(providers, ["openai"]);
expect(mocks.loadPluginManifestRegistry).not.toHaveBeenCalled();
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith(undefined);
});
it.each([
@@ -176,7 +172,7 @@ describe("resolvePluginCapabilityProviders", () => {
it("reuses a compatible active registry even when the capability list is empty", () => {
const active = createEmptyPluginRegistry();
mocks.getCompatibleActivePluginRegistry.mockReturnValue(active);
mocks.resolveRuntimePluginRegistry.mockReturnValue(active);
const providers = resolvePluginCapabilityProviders({
key: "mediaUnderstandingProviders",
@@ -184,6 +180,8 @@ describe("resolvePluginCapabilityProviders", () => {
});
expect(providers).toEqual([]);
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: expect.anything(),
});
});
});

View File

@@ -4,7 +4,7 @@ import {
withBundledPluginEnablementCompat,
withBundledPluginVitestCompat,
} from "./bundled-compat.js";
import { getCompatibleActivePluginRegistry, loadOpenClawPlugins } from "./loader.js";
import { resolveRuntimePluginRegistry } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import type { PluginRegistry } from "./registry.js";
@@ -73,10 +73,7 @@ export function resolvePluginCapabilityProviders<K extends CapabilityProviderReg
: {
config: resolveCapabilityProviderConfig({ key: params.key, cfg: params.cfg }),
};
const registry =
(loadOptions ? getCompatibleActivePluginRegistry(loadOptions) : undefined) ??
(loadOptions ? loadOpenClawPlugins(loadOptions) : undefined) ??
getCompatibleActivePluginRegistry();
const registry = resolveRuntimePluginRegistry(loadOptions);
return (registry?.[params.key] ?? []).map(
(entry) => entry.provider,
) as CapabilityProviderForKey<K>[];

View File

@@ -13,6 +13,7 @@ import {
clearPluginLoaderCache,
getCompatibleActivePluginRegistry,
loadOpenClawPlugins,
resolveRuntimePluginRegistry,
resolvePluginLoadCacheContext,
} from "./loader.js";
import { clearPluginManifestRegistryCache } from "./manifest-registry.js";
@@ -3594,6 +3595,31 @@ describe("getCompatibleActivePluginRegistry", () => {
});
});
describe("resolveRuntimePluginRegistry", () => {
it("reuses the compatible active registry before attempting a fresh load", () => {
const registry = createEmptyPluginRegistry();
const loadOptions = {
config: {
plugins: {
allow: ["demo"],
},
},
workspaceDir: "/tmp/workspace-a",
};
const { cacheKey } = resolvePluginLoadCacheContext(loadOptions);
setActivePluginRegistry(registry, cacheKey);
expect(resolveRuntimePluginRegistry(loadOptions)).toBe(registry);
});
it("falls back to the current active runtime when no explicit load context is provided", () => {
const registry = createEmptyPluginRegistry();
setActivePluginRegistry(registry, "startup-registry");
expect(resolveRuntimePluginRegistry()).toBe(registry);
});
});
describe("clearPluginLoaderCache", () => {
it("resets registered memory plugin registries", () => {
registerMemoryEmbeddingProvider({

View File

@@ -317,6 +317,15 @@ export function getCompatibleActivePluginRegistry(
: undefined;
}
export function resolveRuntimePluginRegistry(
options?: PluginLoadOptions,
): PluginRegistry | undefined {
if (!options || !hasExplicitCompatibilityInputs(options)) {
return getCompatibleActivePluginRegistry();
}
return getCompatibleActivePluginRegistry(options) ?? loadOpenClawPlugins(options);
}
function validatePluginConfig(params: {
schema?: Record<string, unknown>;
cacheKey?: string;

View File

@@ -8,13 +8,11 @@ type MockRegistryToolEntry = {
};
const loadOpenClawPluginsMock = vi.fn();
const getCompatibleActivePluginRegistryMock = vi.fn();
const resolveRuntimePluginRegistryMock = vi.fn();
const applyPluginAutoEnableMock = vi.fn();
vi.mock("./loader.js", () => ({
loadOpenClawPlugins: (params: unknown) => loadOpenClawPluginsMock(params),
getCompatibleActivePluginRegistry: (params: unknown) =>
getCompatibleActivePluginRegistryMock(params),
resolveRuntimePluginRegistry: (params: unknown) => resolveRuntimePluginRegistryMock(params),
}));
vi.mock("../config/plugin-auto-enable.js", () => ({
@@ -137,8 +135,10 @@ describe("resolvePluginTools optional tools", () => {
beforeEach(async () => {
vi.resetModules();
loadOpenClawPluginsMock.mockClear();
getCompatibleActivePluginRegistryMock.mockReset();
getCompatibleActivePluginRegistryMock.mockReturnValue(undefined);
resolveRuntimePluginRegistryMock.mockReset();
resolveRuntimePluginRegistryMock.mockImplementation((params) =>
loadOpenClawPluginsMock(params),
);
applyPluginAutoEnableMock.mockReset();
applyPluginAutoEnableMock.mockImplementation(({ config }: { config: unknown }) => ({
config,
@@ -317,7 +317,7 @@ describe("resolvePluginTools optional tools", () => {
],
diagnostics: [],
};
getCompatibleActivePluginRegistryMock.mockReturnValue(activeRegistry);
resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry);
const tools = resolvePluginTools(
createResolveToolsParams({

View File

@@ -3,7 +3,7 @@ import type { AnyAgentTool } from "../agents/tools/common.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { applyTestPluginDefaults, normalizePluginsConfig } from "./config-state.js";
import { getCompatibleActivePluginRegistry, loadOpenClawPlugins } from "./loader.js";
import { resolveRuntimePluginRegistry } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import type { OpenClawPluginToolContext } from "./types.js";
@@ -79,8 +79,10 @@ export function resolvePluginTools(params: {
env,
logger: createPluginLoaderLogger(log),
};
const registry =
getCompatibleActivePluginRegistry(loadOptions) ?? loadOpenClawPlugins(loadOptions);
const registry = resolveRuntimePluginRegistry(loadOptions);
if (!registry) {
return [];
}
const tools: AnyAgentTool[] = [];
const existing = params.existingToolNames ?? new Set<string>();

View File

@@ -6,7 +6,7 @@ import {
resolvePluginSnapshotCacheTtlMs,
shouldUsePluginSnapshotCache,
} from "./cache-controls.js";
import { getCompatibleActivePluginRegistry, loadOpenClawPlugins } from "./loader.js";
import { loadOpenClawPlugins, resolveRuntimePluginRegistry } from "./loader.js";
import type { PluginLoadOptions } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
@@ -202,10 +202,9 @@ export function resolveRuntimeWebSearchProviders(params: {
bundledAllowlistCompat?: boolean;
onlyPluginIds?: readonly string[];
}): PluginWebSearchProviderEntry[] {
const runtimeRegistry =
params.config === undefined
? getCompatibleActivePluginRegistry()
: getCompatibleActivePluginRegistry(resolveWebSearchLoadOptions(params));
const runtimeRegistry = resolveRuntimePluginRegistry(
params.config === undefined ? undefined : resolveWebSearchLoadOptions(params),
);
if (runtimeRegistry) {
return mapRegistryWebSearchProviders({
registry: runtimeRegistry,

View File

@@ -4,15 +4,11 @@ import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import type { SpeechProviderPlugin } from "../plugins/types.js";
const loadOpenClawPluginsMock = vi.fn();
const getCompatibleActivePluginRegistryMock = vi.fn();
const resolveRuntimePluginRegistryMock = vi.fn();
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins: (...args: Parameters<typeof loadOpenClawPluginsMock>) =>
loadOpenClawPluginsMock(...args),
getCompatibleActivePluginRegistry: (
...args: Parameters<typeof getCompatibleActivePluginRegistryMock>
) => getCompatibleActivePluginRegistryMock(...args),
resolveRuntimePluginRegistry: (...args: Parameters<typeof resolveRuntimePluginRegistryMock>) =>
resolveRuntimePluginRegistryMock(...args),
}));
let getSpeechProvider: typeof import("./provider-registry.js").getSpeechProvider;
@@ -39,10 +35,8 @@ describe("speech provider registry", () => {
beforeEach(async () => {
vi.resetModules();
resetPluginRuntimeStateForTest();
loadOpenClawPluginsMock.mockReset();
loadOpenClawPluginsMock.mockReturnValue(createEmptyPluginRegistry());
getCompatibleActivePluginRegistryMock.mockReset();
getCompatibleActivePluginRegistryMock.mockReturnValue(undefined);
resolveRuntimePluginRegistryMock.mockReset();
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
({
getSpeechProvider,
listSpeechProviders,
@@ -66,7 +60,7 @@ describe("speech provider registry", () => {
},
],
});
getCompatibleActivePluginRegistryMock.mockReturnValue({
resolveRuntimePluginRegistryMock.mockReturnValue({
...createEmptyPluginRegistry(),
speechProviders: [
{
@@ -79,11 +73,11 @@ describe("speech provider registry", () => {
const providers = listSpeechProviders();
expect(providers.map((provider) => provider.id)).toEqual(["demo-speech"]);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(undefined);
});
it("loads speech providers from plugins when config is provided", () => {
loadOpenClawPluginsMock.mockReturnValue({
resolveRuntimePluginRegistryMock.mockReturnValue({
...createEmptyPluginRegistry(),
speechProviders: [
{
@@ -98,7 +92,7 @@ describe("speech provider registry", () => {
expect(listSpeechProviders(cfg).map((provider) => provider.id)).toEqual(["microsoft"]);
expect(getSpeechProvider("edge", cfg)?.id).toBe("microsoft");
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith({
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith({
config: {
plugins: {
entries: {
@@ -114,6 +108,7 @@ describe("speech provider registry", () => {
it("returns no providers when neither plugins nor active registry provide speech support", () => {
expect(listSpeechProviders()).toEqual([]);
expect(getSpeechProvider("demo-speech")).toBeUndefined();
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(undefined);
});
it("canonicalizes the legacy edge alias to microsoft", () => {
@@ -127,7 +122,7 @@ describe("speech provider registry", () => {
},
],
});
getCompatibleActivePluginRegistryMock.mockReturnValue({
resolveRuntimePluginRegistryMock.mockReturnValue({
...createEmptyPluginRegistry(),
speechProviders: [
{