Files
openclaw/src/plugins/loader.runtime-registry.test.ts

627 lines
19 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import {
__testing,
clearPluginLoaderCache,
loadOpenClawPlugins,
resolveRuntimePluginRegistry,
} from "./loader.js";
import { resetPluginLoaderTestStateForTest } from "./loader.test-fixtures.js";
import {
getMemoryEmbeddingProvider,
registerMemoryEmbeddingProvider,
} from "./memory-embedding-providers.js";
import {
buildMemoryPromptSection,
getMemoryRuntime,
listMemoryCorpusSupplements,
registerMemoryCorpusSupplement,
registerMemoryFlushPlanResolver,
registerMemoryPromptSupplement,
registerMemoryPromptSection,
registerMemoryRuntime,
resolveMemoryFlushPlan,
} from "./memory-state.js";
import type { PluginRecord } from "./registry-types.js";
import { createEmptyPluginRegistry } from "./registry.js";
import { setActivePluginRegistry } from "./runtime.js";
import type { CreatePluginRuntimeOptions } from "./runtime/index.js";
afterEach(() => {
resetPluginLoaderTestStateForTest();
});
function createLoadedPluginRecord(id: string): PluginRecord {
return {
id,
name: id,
source: "test",
origin: "workspace",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
cliBackendIds: [],
providerIds: [],
speechProviderIds: [],
realtimeTranscriptionProviderIds: [],
realtimeVoiceProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
videoGenerationProviderIds: [],
musicGenerationProviderIds: [],
webFetchProviderIds: [],
webSearchProviderIds: [],
migrationProviderIds: [],
memoryEmbeddingProviderIds: [],
agentHarnessIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
gatewayDiscoveryServiceIds: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
};
}
describe("getCompatibleActivePluginRegistry", () => {
it("reuses the active registry only when the load context cache key matches", () => {
const registry = createEmptyPluginRegistry();
const loadOptions = {
config: {
plugins: {
allow: ["demo"],
load: { paths: ["/tmp/demo.js"] },
},
},
workspaceDir: "/tmp/workspace-a",
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
};
const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions);
setActivePluginRegistry(registry, cacheKey, "gateway-bindable");
expect(__testing.getCompatibleActivePluginRegistry(loadOptions)).toBe(registry);
expect(
__testing.getCompatibleActivePluginRegistry({
...loadOptions,
workspaceDir: "/tmp/workspace-b",
}),
).toBeUndefined();
expect(
__testing.getCompatibleActivePluginRegistry({
...loadOptions,
onlyPluginIds: ["demo"],
}),
).toBeUndefined();
expect(
__testing.getCompatibleActivePluginRegistry({
...loadOptions,
onlyPluginIds: [],
}),
).toBeUndefined();
expect(
__testing.getCompatibleActivePluginRegistry({
...loadOptions,
runtimeOptions: undefined,
}),
).toBe(registry);
expect(
__testing.getCompatibleActivePluginRegistry({
...loadOptions,
runtimeOptions: {
subagent: {} as CreatePluginRuntimeOptions["subagent"],
},
}),
).toBeUndefined();
});
it("does not treat a default-mode active registry as compatible with gateway binding", () => {
const registry = createEmptyPluginRegistry();
const loadOptions = {
config: {
plugins: {
allow: ["demo"],
load: { paths: ["/tmp/demo.js"] },
},
},
workspaceDir: "/tmp/workspace-a",
};
const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions);
setActivePluginRegistry(registry, cacheKey, "default");
expect(
__testing.getCompatibleActivePluginRegistry({
...loadOptions,
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
}),
).toBeUndefined();
});
it("reuses an active full registry for compatible tool-discovery loads", () => {
const registry = createEmptyPluginRegistry();
const loadOptions = {
config: {
plugins: {
allow: ["demo"],
load: { paths: ["/tmp/demo.js"] },
},
},
workspaceDir: "/tmp/workspace-a",
};
const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions);
setActivePluginRegistry(registry, cacheKey, "default");
expect(
__testing.getCompatibleActivePluginRegistry({
...loadOptions,
activate: false,
toolDiscovery: true,
}),
).toBe(registry);
});
it("reuses an active wider registry for compatible scoped runtime loads", () => {
const registry = createEmptyPluginRegistry();
registry.plugins.push(createLoadedPluginRecord("demo"), createLoadedPluginRecord("other"));
const loadOptions = {
config: {
plugins: {
allow: ["demo", "other"],
load: { paths: ["/tmp/demo.js"] },
},
},
workspaceDir: "/tmp/workspace-a",
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
};
const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions);
setActivePluginRegistry(registry, cacheKey, "gateway-bindable");
expect(
__testing.getCompatibleActivePluginRegistry({
...loadOptions,
onlyPluginIds: ["demo"],
}),
).toBe(registry);
});
it("does not reuse a wider registry for scoped loads when the load context changes", () => {
const registry = createEmptyPluginRegistry();
registry.plugins.push(createLoadedPluginRecord("demo"), createLoadedPluginRecord("other"));
const loadOptions = {
config: {
plugins: {
allow: ["demo", "other"],
load: { paths: ["/tmp/demo.js"] },
},
},
workspaceDir: "/tmp/workspace-a",
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
};
const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions);
setActivePluginRegistry(registry, cacheKey, "gateway-bindable");
expect(
__testing.getCompatibleActivePluginRegistry({
...loadOptions,
workspaceDir: "/tmp/workspace-b",
onlyPluginIds: ["demo"],
}),
).toBeUndefined();
expect(
__testing.getCompatibleActivePluginRegistry({
...loadOptions,
config: {
plugins: {
allow: ["demo"],
load: { paths: ["/tmp/changed.js"] },
},
},
onlyPluginIds: ["demo"],
}),
).toBeUndefined();
expect(
__testing.getCompatibleActivePluginRegistry({
...loadOptions,
onlyPluginIds: ["missing"],
}),
).toBeUndefined();
});
it("does not reuse a default-mode active registry for gateway-bindable tool discovery", () => {
const registry = createEmptyPluginRegistry();
const loadOptions = {
config: {
plugins: {
allow: ["demo"],
load: { paths: ["/tmp/demo.js"] },
},
},
workspaceDir: "/tmp/workspace-a",
};
const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions);
setActivePluginRegistry(registry, cacheKey, "default");
expect(
__testing.getCompatibleActivePluginRegistry({
...loadOptions,
activate: false,
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
toolDiscovery: true,
}),
).toBeUndefined();
});
it("does not embed activation secrets in the loader cache key", () => {
const { cacheKey } = __testing.resolvePluginLoadCacheContext({
config: {
plugins: {
allow: ["telegram"],
},
},
activationSourceConfig: {
plugins: {
allow: ["telegram"],
},
channels: {
telegram: {
enabled: true,
botToken: "secret-token",
},
},
},
autoEnabledReasons: {
telegram: ["telegram configured"],
},
});
expect(cacheKey).not.toContain("secret-token");
expect(cacheKey).not.toContain("botToken");
expect(cacheKey).not.toContain("telegram configured");
});
it("falls back to the current active runtime when no compatibility-shaping inputs are supplied", () => {
const registry = createEmptyPluginRegistry();
setActivePluginRegistry(registry, "startup-registry");
expect(__testing.getCompatibleActivePluginRegistry()).toBe(registry);
});
it("does not reuse the active registry when core gateway method names differ", () => {
const registry = createEmptyPluginRegistry();
const loadOptions = {
config: {
plugins: {
allow: ["demo"],
load: { paths: ["/tmp/demo.js"] },
},
},
workspaceDir: "/tmp/workspace-a",
coreGatewayHandlers: {
"sessions.get": () => undefined,
},
};
const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions);
setActivePluginRegistry(registry, cacheKey);
expect(__testing.getCompatibleActivePluginRegistry(loadOptions)).toBe(registry);
expect(
__testing.getCompatibleActivePluginRegistry({
...loadOptions,
coreGatewayHandlers: {
"sessions.get": () => undefined,
"sessions.list": () => undefined,
},
}),
).toBeUndefined();
});
it("reuses a scoped gateway-bindable registry for a matching default-mode tool scope", () => {
const registry = createEmptyPluginRegistry();
registry.plugins.push(
{ id: "acpx" } as (typeof registry.plugins)[number],
{ id: "telegram" } as (typeof registry.plugins)[number],
);
const startupOptions = {
config: {
plugins: {
allow: ["acpx", "telegram"],
},
},
workspaceDir: "/tmp/workspace-a",
onlyPluginIds: ["acpx", "telegram"],
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
};
const { cacheKey } = __testing.resolvePluginLoadCacheContext(startupOptions);
setActivePluginRegistry(registry, cacheKey, "gateway-bindable");
expect(
__testing.getCompatibleActivePluginRegistry({
config: startupOptions.config,
workspaceDir: "/tmp/workspace-a",
onlyPluginIds: ["acpx", "telegram"],
}),
).toBe(registry);
});
it("reuses a scoped gateway-bindable registry for a matching snapshot-mode tool scope", () => {
const registry = createEmptyPluginRegistry();
registry.plugins.push(
{ id: "acpx" } as (typeof registry.plugins)[number],
{ id: "telegram" } as (typeof registry.plugins)[number],
);
const startupOptions = {
config: {
plugins: {
allow: ["acpx", "telegram"],
},
},
workspaceDir: "/tmp/workspace-a",
onlyPluginIds: ["acpx", "telegram"],
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
};
const { cacheKey } = __testing.resolvePluginLoadCacheContext(startupOptions);
setActivePluginRegistry(registry, cacheKey, "gateway-bindable");
expect(
__testing.getCompatibleActivePluginRegistry({
config: startupOptions.config,
workspaceDir: "/tmp/workspace-a",
onlyPluginIds: ["acpx", "telegram"],
activate: false,
}),
).toBe(registry);
});
it("does not reuse a scoped registry when the requested tool scope needs another plugin", () => {
const registry = createEmptyPluginRegistry();
registry.plugins.push(
{ id: "acpx" } as (typeof registry.plugins)[number],
{ id: "telegram" } as (typeof registry.plugins)[number],
);
const startupOptions = {
config: {
plugins: {
allow: ["acpx", "telegram", "tavily"],
},
},
workspaceDir: "/tmp/workspace-a",
onlyPluginIds: ["acpx", "telegram"],
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
};
const { cacheKey } = __testing.resolvePluginLoadCacheContext(startupOptions);
setActivePluginRegistry(registry, cacheKey, "gateway-bindable");
expect(
__testing.getCompatibleActivePluginRegistry({
config: startupOptions.config,
workspaceDir: "/tmp/workspace-a",
onlyPluginIds: ["acpx", "telegram", "tavily"],
}),
).toBeUndefined();
});
it("does not treat an unscoped request as compatible with the scoped startup registry", () => {
const registry = createEmptyPluginRegistry();
registry.plugins.push(
{ id: "acpx" } as (typeof registry.plugins)[number],
{ id: "telegram" } as (typeof registry.plugins)[number],
);
const startupOptions = {
config: {
plugins: {
allow: ["acpx", "telegram", "tavily"],
},
},
workspaceDir: "/tmp/workspace-a",
onlyPluginIds: ["acpx", "telegram"],
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
};
const { cacheKey } = __testing.resolvePluginLoadCacheContext(startupOptions);
setActivePluginRegistry(registry, cacheKey, "gateway-bindable");
expect(
__testing.getCompatibleActivePluginRegistry({
config: startupOptions.config,
workspaceDir: "/tmp/workspace-a",
}),
).toBeUndefined();
});
it("does not reuse a scoped gateway-bindable registry for an explicit subagent request", () => {
const registry = createEmptyPluginRegistry();
registry.plugins.push(
{ id: "acpx" } as (typeof registry.plugins)[number],
{ id: "telegram" } as (typeof registry.plugins)[number],
);
const startupOptions = {
config: {
plugins: {
allow: ["acpx", "telegram"],
},
},
workspaceDir: "/tmp/workspace-a",
onlyPluginIds: ["acpx", "telegram"],
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
};
const { cacheKey } = __testing.resolvePluginLoadCacheContext(startupOptions);
setActivePluginRegistry(registry, cacheKey, "gateway-bindable");
expect(
__testing.getCompatibleActivePluginRegistry({
config: startupOptions.config,
workspaceDir: "/tmp/workspace-a",
runtimeOptions: {
subagent: {} as CreatePluginRuntimeOptions["subagent"],
},
}),
).toBeUndefined();
});
it("reuses a scoped startup registry when only the request omits gateway methods", () => {
const registry = createEmptyPluginRegistry();
registry.plugins.push(
{ id: "acpx" } as (typeof registry.plugins)[number],
{ id: "telegram" } as (typeof registry.plugins)[number],
);
registry.coreGatewayMethodNames = ["sessions.get", "sessions.list"];
const startupOptions = {
config: {
plugins: {
allow: ["acpx", "telegram"],
},
},
workspaceDir: "/tmp/workspace-a",
onlyPluginIds: ["acpx", "telegram"],
coreGatewayMethodNames: ["sessions.get", "sessions.list"],
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
};
const { cacheKey } = __testing.resolvePluginLoadCacheContext(startupOptions);
setActivePluginRegistry(registry, cacheKey, "gateway-bindable");
expect(
__testing.getCompatibleActivePluginRegistry({
config: startupOptions.config,
workspaceDir: "/tmp/workspace-a",
onlyPluginIds: ["acpx", "telegram"],
}),
).toBe(registry);
});
});
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 } = __testing.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);
});
it("does not treat an explicit empty plugin scope as the active runtime", () => {
const registry = createEmptyPluginRegistry();
const loadOptions = {
config: {
plugins: {
allow: ["demo"],
load: { paths: ["/tmp/demo.js"] },
},
},
workspaceDir: "/tmp/workspace-a",
};
const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions);
setActivePluginRegistry(registry, cacheKey);
const scopedEmpty = resolveRuntimePluginRegistry({ ...loadOptions, onlyPluginIds: [] });
expect(scopedEmpty).not.toBe(registry);
expect(scopedEmpty?.plugins).toEqual([]);
});
it("keeps the full workspace registry warm when scoped cron registries churn", () => {
__testing.setMaxPluginRegistryCacheEntriesForTest(2);
try {
const loadOptions = {
config: {
plugins: {
allow: ["alpha", "bravo", "charlie"],
},
},
workspaceDir: "/tmp/workspace-a",
};
const fullRegistry = loadOpenClawPlugins(loadOptions);
loadOpenClawPlugins({ ...loadOptions, onlyPluginIds: ["alpha"] });
loadOpenClawPlugins({ ...loadOptions, onlyPluginIds: ["bravo"] });
expect(resolveRuntimePluginRegistry(loadOptions)).toBe(fullRegistry);
} finally {
__testing.setMaxPluginRegistryCacheEntriesForTest();
}
});
});
describe("clearPluginLoaderCache", () => {
it("resets registered memory plugin registries", () => {
registerMemoryEmbeddingProvider({
id: "stale",
create: async () => ({ provider: null }),
});
registerMemoryCorpusSupplement("memory-wiki", {
search: async () => [],
get: async () => null,
});
registerMemoryPromptSection(() => ["stale memory section"]);
registerMemoryPromptSupplement("memory-wiki", () => ["stale wiki supplement"]);
registerMemoryFlushPlanResolver(() => ({
softThresholdTokens: 1,
forceFlushTranscriptBytes: 2,
reserveTokensFloor: 3,
prompt: "stale",
systemPrompt: "stale",
relativePath: "memory/stale.md",
}));
registerMemoryRuntime({
async getMemorySearchManager() {
return { manager: null };
},
resolveMemoryBackendConfig() {
return { backend: "builtin" as const };
},
});
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([
"stale memory section",
"stale wiki supplement",
]);
expect(listMemoryCorpusSupplements()).toHaveLength(1);
expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/stale.md");
expect(getMemoryRuntime()).toBeDefined();
expect(getMemoryEmbeddingProvider("stale")).toBeDefined();
clearPluginLoaderCache();
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([]);
expect(listMemoryCorpusSupplements()).toEqual([]);
expect(resolveMemoryFlushPlan({})).toBeNull();
expect(getMemoryRuntime()).toBeUndefined();
expect(getMemoryEmbeddingProvider("stale")).toBeUndefined();
});
});