fix: support multi-kind plugins for dual slot ownership (#57507) (thanks @fuller-stack-dev)

* feat(plugins): support multi-kind plugins for dual slot ownership

* fix: address review feedback on multi-kind plugin support

- Use sorted normalizeKinds() for kind-mismatch comparison in loader.ts
  (fixes order-sensitive JSON.stringify for arrays)
- Derive slot-to-kind reverse mapping from SLOT_BY_KIND in slots.ts
  (removes hardcoded ternary that would break for future slot types)
- Use shared hasKind() helper in config-state.ts instead of inline logic

* fix: don't disable dual-kind plugin that still owns another slot

When a new plugin takes over one slot, a dual-kind plugin that still
owns the other slot must not be disabled — otherwise context engine
resolution fails at runtime.

* fix: exempt dual-kind plugins from memory slot disablement

A plugin with kind: ["memory", "context-engine"] must stay enabled even
when it loses the memory slot, so its context engine role can still load.

* fix: address remaining review feedback

- Pass manifest kind (not hardcoded "memory") in early memory gating
- Extract kindsEqual() helper for DRY kind comparison in loader.ts
- Narrow slotKeyForPluginKind back to single PluginKind with JSDoc
- Reject empty array in parsePluginKind
- Add kindsEqual tests

* fix: use toSorted() instead of sort() per lint rules

* plugins: include default slot ownership in disable checks and gate dual-kind memory registration
This commit is contained in:
fuller-stack-dev 2026-03-30 22:36:48 -06:00 committed by GitHub
parent 10ac6ead6b
commit 235908c30e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 520 additions and 84 deletions

View File

@ -8,6 +8,7 @@ import {
resolveMemorySlotDecision,
} from "../../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
import { hasKind } from "../../plugins/slots.js";
import { isPathInsideWithRealpath } from "../../security/scan-paths.js";
const log = createSubsystemLogger("skills");
@ -60,7 +61,7 @@ export function resolvePluginSkillDirs(params: {
if (!memoryDecision.enabled) {
continue;
}
if (memoryDecision.selected && record.kind === "memory") {
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
selectedMemoryPluginId = record.id;
}
for (const raw of record.skills) {

View File

@ -10,6 +10,7 @@ import {
} from "../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import { hasKind } from "../plugins/slots.js";
import {
hasAvatarUriScheme,
isAvatarDataUrl,
@ -847,7 +848,7 @@ function validateConfigObjectWithPluginsBase(
enabled = false;
reason = memoryDecision.reason;
}
if (memoryDecision.selected && record.kind === "memory") {
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
selectedMemoryPluginId = pluginId;
}
}

View File

@ -8,6 +8,7 @@ import {
resolveMemorySlotDecision,
} from "../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { hasKind } from "../plugins/slots.js";
import { isPathInsideWithRealpath } from "../security/scan-paths.js";
const log = createSubsystemLogger("hooks");
@ -64,7 +65,7 @@ export function resolvePluginHookDirs(params: {
if (!memoryDecision.enabled) {
continue;
}
if (memoryDecision.selected && record.kind === "memory") {
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
selectedMemoryPluginId = record.id;
}

View File

@ -3,6 +3,7 @@ import {
normalizePluginsConfig,
resolveEffectiveEnableState,
resolveEnableState,
resolveMemorySlotDecision,
} from "./config-state.js";
function normalizeVoiceCallEntry(entry: Record<string, unknown>) {
@ -267,3 +268,57 @@ describe("resolveEnableState", () => {
});
});
});
describe("resolveMemorySlotDecision", () => {
it("disables a memory-only plugin when slot points elsewhere", () => {
const result = resolveMemorySlotDecision({
id: "old-memory",
kind: "memory",
slot: "new-memory",
selectedId: null,
});
expect(result.enabled).toBe(false);
});
it("keeps a dual-kind plugin enabled when memory slot points elsewhere", () => {
const result = resolveMemorySlotDecision({
id: "dual-plugin",
kind: ["memory", "context-engine"],
slot: "new-memory",
selectedId: null,
});
expect(result.enabled).toBe(true);
expect(result.selected).toBeUndefined();
});
it("selects a dual-kind plugin when it owns the memory slot", () => {
const result = resolveMemorySlotDecision({
id: "dual-plugin",
kind: ["memory", "context-engine"],
slot: "dual-plugin",
selectedId: null,
});
expect(result.enabled).toBe(true);
expect(result.selected).toBe(true);
});
it("keeps a dual-kind plugin enabled when memory slot is null", () => {
const result = resolveMemorySlotDecision({
id: "dual-plugin",
kind: ["memory", "context-engine"],
slot: null,
selectedId: null,
});
expect(result.enabled).toBe(true);
});
it("disables a memory-only plugin when memory slot is null", () => {
const result = resolveMemorySlotDecision({
id: "old-memory",
kind: "memory",
slot: null,
selectedId: null,
});
expect(result.enabled).toBe(false);
});
});

View File

@ -5,7 +5,8 @@ import {
BUNDLED_PROVIDER_PLUGIN_ID_ALIASES,
} from "./bundled-capability-metadata.js";
import type { PluginRecord } from "./registry.js";
import { defaultSlotIdForKey } from "./slots.js";
import { defaultSlotIdForKey, hasKind } from "./slots.js";
import type { PluginKind } from "./types.js";
export type NormalizedPluginsConfig = {
enabled: boolean;
@ -312,30 +313,33 @@ export function resolveEffectiveEnableState(params: {
export function resolveMemorySlotDecision(params: {
id: string;
kind?: string;
kind?: string | string[];
slot: string | null | undefined;
selectedId: string | null;
}): { enabled: boolean; reason?: string; selected?: boolean } {
if (params.kind !== "memory") {
if (!hasKind(params.kind as PluginKind | PluginKind[] | undefined, "memory")) {
return { enabled: true };
}
// A dual-kind plugin (e.g. ["memory", "context-engine"]) that lost the
// memory slot must stay enabled so its other slot role can still load.
const isMultiKind = Array.isArray(params.kind) && params.kind.length > 1;
if (params.slot === null) {
return { enabled: false, reason: "memory slot disabled" };
return isMultiKind
? { enabled: true }
: { enabled: false, reason: "memory slot disabled" };
}
if (typeof params.slot === "string") {
if (params.slot === params.id) {
return { enabled: true, selected: true };
}
return {
enabled: false,
reason: `memory slot set to "${params.slot}"`,
};
return isMultiKind
? { enabled: true }
: { enabled: false, reason: `memory slot set to "${params.slot}"` };
}
if (params.selectedId && params.selectedId !== params.id) {
return {
enabled: false,
reason: `memory slot already filled by "${params.selectedId}"`,
};
return isMultiKind
? { enabled: true }
: { enabled: false, reason: `memory slot already filled by "${params.selectedId}"` };
}
return { enabled: true, selected: true };
}

View File

@ -59,6 +59,7 @@ import {
resolvePluginSdkScopedAliasMap,
shouldPreferNativeJiti,
} from "./sdk-alias.js";
import { hasKind, kindsEqual } from "./slots.js";
import type {
OpenClawPluginDefinition,
OpenClawPluginModule,
@ -1162,11 +1163,11 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
if (
registrationMode === "full" &&
candidate.origin === "bundled" &&
manifestRecord.kind === "memory"
hasKind(manifestRecord.kind, "memory")
) {
const earlyMemoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: "memory",
kind: manifestRecord.kind,
slot: memorySlot,
selectedId: selectedMemoryPluginId,
});
@ -1262,19 +1263,19 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
record.name = definition?.name ?? record.name;
record.description = definition?.description ?? record.description;
record.version = definition?.version ?? record.version;
const manifestKind = record.kind as string | undefined;
const exportKind = definition?.kind as string | undefined;
if (manifestKind && exportKind && exportKind !== manifestKind) {
const manifestKind = record.kind;
const exportKind = definition?.kind;
if (manifestKind && exportKind && !kindsEqual(manifestKind, exportKind)) {
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`,
message: `plugin kind mismatch (manifest uses "${String(manifestKind)}", export uses "${String(exportKind)}")`,
});
}
record.kind = definition?.kind ?? record.kind;
if (record.kind === "memory" && memorySlot === record.id) {
if (hasKind(record.kind, "memory") && memorySlot === record.id) {
memorySlotMatched = true;
}
@ -1295,8 +1296,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
if (memoryDecision.selected && record.kind === "memory") {
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
selectedMemoryPluginId = record.id;
record.memorySlotSelected = true;
}
}
@ -1626,14 +1628,14 @@ export async function loadOpenClawPluginCliRegistry(
record.name = definition?.name ?? record.name;
record.description = definition?.description ?? record.description;
record.version = definition?.version ?? record.version;
const manifestKind = record.kind as string | undefined;
const exportKind = definition?.kind as string | undefined;
if (manifestKind && exportKind && exportKind !== manifestKind) {
const manifestKind = record.kind;
const exportKind = definition?.kind;
if (manifestKind && exportKind && !kindsEqual(manifestKind, exportKind)) {
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`,
message: `plugin kind mismatch (manifest uses "${String(manifestKind)}", export uses "${String(exportKind)}")`,
});
}
record.kind = definition?.kind ?? record.kind;
@ -1652,8 +1654,9 @@ export async function loadOpenClawPluginCliRegistry(
seenIds.set(pluginId, candidate.origin);
continue;
}
if (memoryDecision.selected && record.kind === "memory") {
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
selectedMemoryPluginId = record.id;
record.memorySlotSelected = true;
}
if (typeof register !== "function") {

View File

@ -49,7 +49,7 @@ export type PluginManifestRecord = {
format?: PluginFormat;
bundleFormat?: PluginBundleFormat;
bundleCapabilities?: string[];
kind?: PluginKind;
kind?: PluginKind | PluginKind[];
channels: string[];
providers: string[];
cliBackends: string[];

View File

@ -24,7 +24,7 @@ export type PluginManifest = {
legacyPluginIds?: string[];
/** Provider ids that should auto-enable this plugin when referenced in auth/config/models. */
autoEnableWhenConfiguredProviders?: string[];
kind?: PluginKind;
kind?: PluginKind | PluginKind[];
channels?: string[];
providers?: string[];
/** Cheap startup activation lookup for plugin-owned CLI inference backends. */
@ -233,6 +233,16 @@ export function resolvePluginManifestPath(rootDir: string): string {
return path.join(rootDir, PLUGIN_MANIFEST_FILENAME);
}
function parsePluginKind(raw: unknown): PluginKind | PluginKind[] | undefined {
if (typeof raw === "string") {
return raw as PluginKind;
}
if (Array.isArray(raw) && raw.length > 0 && raw.every((k) => typeof k === "string")) {
return raw.length === 1 ? (raw[0] as PluginKind) : (raw as PluginKind[]);
}
return undefined;
}
export function loadPluginManifest(
rootDir: string,
rejectHardlinks = true,
@ -282,7 +292,7 @@ export function loadPluginManifest(
return { ok: false, error: "plugin manifest requires configSchema", manifestPath };
}
const kind = typeof raw.kind === "string" ? (raw.kind as PluginKind) : undefined;
const kind = parsePluginKind(raw.kind);
const enabledByDefault = raw.enabledByDefault === true;
const legacyPluginIds = normalizeStringList(raw.legacyPluginIds);
const autoEnableWhenConfiguredProviders = normalizeStringList(

View File

@ -0,0 +1,95 @@
import { afterEach, describe, expect, it } from "vitest";
import {
createPluginRegistryFixture,
registerTestPlugin,
registerVirtualTestPlugin,
} from "./contracts/testkit.js";
import { clearMemoryEmbeddingProviders } from "./memory-embedding-providers.js";
import { _resetMemoryPluginState, getMemoryRuntime } from "./memory-state.js";
import { createPluginRecord } from "./status.test-helpers.js";
afterEach(() => {
_resetMemoryPluginState();
clearMemoryEmbeddingProviders();
});
function createStubMemoryRuntime() {
return {
async getMemorySearchManager() {
return { manager: null, error: "missing" } as const;
},
resolveMemoryBackendConfig() {
return { backend: "builtin" as const };
},
};
}
describe("dual-kind memory registration gate", () => {
it("blocks memory runtime registration for dual-kind plugins not selected for memory slot", () => {
const { config, registry } = createPluginRegistryFixture();
registerVirtualTestPlugin({
registry,
config,
id: "dual-plugin",
name: "Dual Plugin",
kind: ["memory", "context-engine"],
register(api) {
api.registerMemoryRuntime(createStubMemoryRuntime());
},
});
expect(getMemoryRuntime()).toBeUndefined();
expect(registry.registry.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId: "dual-plugin",
level: "warn",
message: expect.stringContaining("dual-kind plugin not selected for memory slot"),
}),
]),
);
});
it("allows memory runtime registration for dual-kind plugins selected for memory slot", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "dual-plugin",
name: "Dual Plugin",
kind: ["memory", "context-engine"],
memorySlotSelected: true,
}),
register(api) {
api.registerMemoryRuntime(createStubMemoryRuntime());
},
});
expect(getMemoryRuntime()).toBeDefined();
expect(
registry.registry.diagnostics.filter(
(d) => d.pluginId === "dual-plugin" && d.level === "warn",
),
).toHaveLength(0);
});
it("allows memory runtime registration for single-kind memory plugins without memorySlotSelected", () => {
const { config, registry } = createPluginRegistryFixture();
registerVirtualTestPlugin({
registry,
config,
id: "memory-only",
name: "Memory Only",
kind: "memory",
register(api) {
api.registerMemoryRuntime(createStubMemoryRuntime());
},
});
expect(getMemoryRuntime()).toBeDefined();
});
});

View File

@ -28,7 +28,7 @@ import { normalizeRegisteredProvider } from "./provider-validation.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js";
import type { PluginRuntime } from "./runtime/types.js";
import { defaultSlotIdForKey } from "./slots.js";
import { defaultSlotIdForKey, hasKind } from "./slots.js";
import {
isPluginHookName,
isPromptInjectionHookName,
@ -188,7 +188,7 @@ export type PluginRecord = {
format?: PluginFormat;
bundleFormat?: PluginBundleFormat;
bundleCapabilities?: string[];
kind?: PluginKind;
kind?: PluginKind | PluginKind[];
source: string;
rootDir?: string;
origin: PluginOrigin;
@ -214,6 +214,7 @@ export type PluginRecord = {
configSchema: boolean;
configUiHints?: Record<string, PluginConfigUiHint>;
configJsonSchema?: Record<string, unknown>;
memorySlotSelected?: boolean;
};
export type PluginRegistry = {
@ -1034,7 +1035,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
}
},
registerMemoryPromptSection: (builder) => {
if (record.kind !== "memory") {
if (!hasKind(record.kind, "memory")) {
pushDiagnostic({
level: "error",
pluginId: record.id,
@ -1043,10 +1044,24 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
return;
}
if (
Array.isArray(record.kind) &&
record.kind.length > 1 &&
!record.memorySlotSelected
) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message:
"dual-kind plugin not selected for memory slot; skipping memory prompt section registration",
});
return;
}
registerMemoryPromptSection(builder);
},
registerMemoryFlushPlan: (resolver) => {
if (record.kind !== "memory") {
if (!hasKind(record.kind, "memory")) {
pushDiagnostic({
level: "error",
pluginId: record.id,
@ -1055,10 +1070,24 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
return;
}
if (
Array.isArray(record.kind) &&
record.kind.length > 1 &&
!record.memorySlotSelected
) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message:
"dual-kind plugin not selected for memory slot; skipping memory flush plan registration",
});
return;
}
registerMemoryFlushPlanResolver(resolver);
},
registerMemoryRuntime: (runtime) => {
if (record.kind !== "memory") {
if (!hasKind(record.kind, "memory")) {
pushDiagnostic({
level: "error",
pluginId: record.id,
@ -1067,10 +1096,24 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
return;
}
if (
Array.isArray(record.kind) &&
record.kind.length > 1 &&
!record.memorySlotSelected
) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message:
"dual-kind plugin not selected for memory slot; skipping memory runtime registration",
});
return;
}
registerMemoryRuntime(runtime);
},
registerMemoryEmbeddingProvider: (adapter) => {
if (record.kind !== "memory") {
if (!hasKind(record.kind, "memory")) {
pushDiagnostic({
level: "error",
pluginId: record.id,
@ -1079,6 +1122,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
return;
}
if (
Array.isArray(record.kind) &&
record.kind.length > 1 &&
!record.memorySlotSelected
) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message:
"dual-kind plugin not selected for memory slot; skipping memory embedding provider registration",
});
return;
}
const existing = getRegisteredMemoryEmbeddingProvider(adapter.id);
if (existing) {
const ownerDetail = existing.ownerPluginId

View File

@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { applyExclusiveSlotSelection } from "./slots.js";
import {
applyExclusiveSlotSelection,
hasKind,
kindsEqual,
normalizeKinds,
slotKeysForPluginKind,
} from "./slots.js";
import type { PluginKind } from "./types.js";
describe("applyExclusiveSlotSelection", () => {
@ -69,7 +75,9 @@ describe("applyExclusiveSlotSelection", () => {
expect(result.warnings).toHaveLength(0);
}
function buildSelectionRegistry(plugins: ReadonlyArray<{ id: string; kind?: PluginKind }>) {
function buildSelectionRegistry(
plugins: ReadonlyArray<{ id: string; kind?: PluginKind | PluginKind[] }>,
) {
return {
plugins: [...plugins],
};
@ -78,8 +86,8 @@ describe("applyExclusiveSlotSelection", () => {
function expectUnchangedSelectionCase(params: {
config: OpenClawConfig;
selectedId: string;
selectedKind?: PluginKind;
registry?: { plugins: ReadonlyArray<{ id: string; kind?: PluginKind }> };
selectedKind?: PluginKind | PluginKind[];
registry?: { plugins: ReadonlyArray<{ id: string; kind?: PluginKind | PluginKind[] }> };
}) {
const result = applyExclusiveSlotSelection({
config: params.config,
@ -183,4 +191,153 @@ describe("applyExclusiveSlotSelection", () => {
...(registry ? { registry: buildSelectionRegistry(registry.plugins) } : {}),
});
});
it("applies slot selection for each kind in a multi-kind array", () => {
const config: OpenClawConfig = {
plugins: {
slots: { memory: "memory-core", contextEngine: "legacy" },
entries: {
"memory-core": { enabled: true },
legacy: { enabled: true },
},
},
};
const result = applyExclusiveSlotSelection({
config,
selectedId: "dual-plugin",
selectedKind: ["memory", "context-engine"],
registry: buildSelectionRegistry([
{ id: "memory-core", kind: "memory" },
{ id: "legacy", kind: "context-engine" },
{ id: "dual-plugin", kind: ["memory", "context-engine"] },
]),
});
expect(result.changed).toBe(true);
expect(result.config.plugins?.slots?.memory).toBe("dual-plugin");
expect(result.config.plugins?.slots?.contextEngine).toBe("dual-plugin");
expect(result.config.plugins?.entries?.["memory-core"]?.enabled).toBe(false);
expect(result.config.plugins?.entries?.legacy?.enabled).toBe(false);
});
it("does not disable a dual-kind plugin that still owns another slot", () => {
const config: OpenClawConfig = {
plugins: {
slots: { memory: "dual-plugin", contextEngine: "dual-plugin" },
entries: {
"dual-plugin": { enabled: true },
},
},
};
const result = applyExclusiveSlotSelection({
config,
selectedId: "new-memory",
selectedKind: "memory",
registry: buildSelectionRegistry([
{ id: "dual-plugin", kind: ["memory", "context-engine"] },
{ id: "new-memory", kind: "memory" },
]),
});
expect(result.changed).toBe(true);
expect(result.config.plugins?.slots?.memory).toBe("new-memory");
// dual-plugin still owns contextEngine — must NOT be disabled
expect(result.config.plugins?.entries?.["dual-plugin"]?.enabled).not.toBe(false);
});
it("does not disable a dual-kind plugin that owns another slot via default", () => {
// contextEngine is NOT explicitly set — defaults to "legacy"
const config: OpenClawConfig = {
plugins: {
slots: { memory: "legacy" },
entries: {
legacy: { enabled: true },
},
},
};
const result = applyExclusiveSlotSelection({
config,
selectedId: "new-memory",
selectedKind: "memory",
registry: buildSelectionRegistry([
{ id: "legacy", kind: ["memory", "context-engine"] },
{ id: "new-memory", kind: "memory" },
]),
});
expect(result.changed).toBe(true);
expect(result.config.plugins?.slots?.memory).toBe("new-memory");
// legacy still owns contextEngine via default — must NOT be disabled
expect(result.config.plugins?.entries?.legacy?.enabled).not.toBe(false);
});
});
describe("normalizeKinds", () => {
it("returns empty array for undefined", () => {
expect(normalizeKinds(undefined)).toEqual([]);
});
it("wraps a single kind in an array", () => {
expect(normalizeKinds("memory")).toEqual(["memory"]);
});
it("returns an array kind as-is", () => {
expect(normalizeKinds(["memory", "context-engine"])).toEqual(["memory", "context-engine"]);
});
});
describe("hasKind", () => {
it("returns false for undefined kind", () => {
expect(hasKind(undefined, "memory")).toBe(false);
});
it("matches a single kind string", () => {
expect(hasKind("memory", "memory")).toBe(true);
expect(hasKind("memory", "context-engine")).toBe(false);
});
it("matches within a kind array", () => {
expect(hasKind(["memory", "context-engine"], "memory")).toBe(true);
expect(hasKind(["memory", "context-engine"], "context-engine")).toBe(true);
});
});
describe("slotKeysForPluginKind", () => {
it("returns empty for undefined", () => {
expect(slotKeysForPluginKind(undefined)).toEqual([]);
});
it("returns single slot key for single kind", () => {
expect(slotKeysForPluginKind("memory")).toEqual(["memory"]);
});
it("returns multiple slot keys for multi-kind", () => {
expect(slotKeysForPluginKind(["memory", "context-engine"])).toEqual([
"memory",
"contextEngine",
]);
});
});
describe("kindsEqual", () => {
it("treats undefined as equal to undefined", () => {
expect(kindsEqual(undefined, undefined)).toBe(true);
});
it("matches identical strings", () => {
expect(kindsEqual("memory", "memory")).toBe(true);
});
it("rejects different strings", () => {
expect(kindsEqual("memory", "context-engine")).toBe(false);
});
it("matches arrays in different order", () => {
expect(kindsEqual(["memory", "context-engine"], ["context-engine", "memory"])).toBe(true);
});
it("matches string against single-element array", () => {
expect(kindsEqual("memory", ["memory"])).toBe(true);
});
it("rejects mismatched lengths", () => {
expect(kindsEqual("memory", ["memory", "context-engine"])).toBe(false);
});
});

View File

@ -6,7 +6,7 @@ export type PluginSlotKey = keyof PluginSlotsConfig;
type SlotPluginRecord = {
id: string;
kind?: PluginKind;
kind?: PluginKind | PluginKind[];
};
const SLOT_BY_KIND: Record<PluginKind, PluginSlotKey> = {
@ -19,6 +19,26 @@ const DEFAULT_SLOT_BY_KEY: Record<PluginSlotKey, string> = {
contextEngine: "legacy",
};
/** Normalize a kind field to an array for uniform iteration. */
export function normalizeKinds(kind?: PluginKind | PluginKind[]): PluginKind[] {
if (!kind) {
return [];
}
return Array.isArray(kind) ? kind : [kind];
}
/** Check whether a plugin's kind field includes a specific kind. */
export function hasKind(kind: PluginKind | PluginKind[] | undefined, target: PluginKind): boolean {
if (!kind) {
return false;
}
return Array.isArray(kind) ? kind.includes(target) : kind === target;
}
/**
* Returns the slot key for a single-kind plugin.
* For multi-kind plugins use `slotKeysForPluginKind` instead.
*/
export function slotKeyForPluginKind(kind?: PluginKind): PluginSlotKey | null {
if (!kind) {
return null;
@ -26,6 +46,23 @@ export function slotKeyForPluginKind(kind?: PluginKind): PluginSlotKey | null {
return SLOT_BY_KIND[kind] ?? null;
}
/** Order-insensitive equality check for two kind values (string or array). */
export function kindsEqual(
a: PluginKind | PluginKind[] | undefined,
b: PluginKind | PluginKind[] | undefined,
): boolean {
const aN = normalizeKinds(a).toSorted();
const bN = normalizeKinds(b).toSorted();
return aN.length === bN.length && aN.every((k, i) => k === bN[i]);
}
/** Return all slot keys that a plugin's kind field maps to. */
export function slotKeysForPluginKind(kind?: PluginKind | PluginKind[]): PluginSlotKey[] {
return normalizeKinds(kind)
.map((k) => SLOT_BY_KIND[k])
.filter((k): k is PluginSlotKey => k != null);
}
export function defaultSlotIdForKey(slotKey: PluginSlotKey): string {
return DEFAULT_SLOT_BY_KEY[slotKey];
}
@ -39,59 +76,74 @@ export type SlotSelectionResult = {
export function applyExclusiveSlotSelection(params: {
config: OpenClawConfig;
selectedId: string;
selectedKind?: PluginKind;
selectedKind?: PluginKind | PluginKind[];
registry?: { plugins: SlotPluginRecord[] };
}): SlotSelectionResult {
const slotKey = slotKeyForPluginKind(params.selectedKind);
if (!slotKey) {
const slotKeys = slotKeysForPluginKind(params.selectedKind);
if (slotKeys.length === 0) {
return { config: params.config, warnings: [], changed: false };
}
const warnings: string[] = [];
const pluginsConfig = params.config.plugins ?? {};
const prevSlot = pluginsConfig.slots?.[slotKey];
const slots = {
...pluginsConfig.slots,
[slotKey]: params.selectedId,
};
let pluginsConfig = params.config.plugins ?? {};
let anyChanged = false;
let entries = { ...pluginsConfig.entries };
let slots = { ...pluginsConfig.slots };
const inferredPrevSlot = prevSlot ?? defaultSlotIdForKey(slotKey);
if (inferredPrevSlot && inferredPrevSlot !== params.selectedId) {
warnings.push(
`Exclusive slot "${slotKey}" switched from "${inferredPrevSlot}" to "${params.selectedId}".`,
);
}
for (const slotKey of slotKeys) {
const prevSlot = slots[slotKey];
slots = { ...slots, [slotKey]: params.selectedId };
const entries = { ...pluginsConfig.entries };
const disabledIds: string[] = [];
if (params.registry) {
for (const plugin of params.registry.plugins) {
if (plugin.id === params.selectedId) {
continue;
}
if (plugin.kind !== params.selectedKind) {
continue;
}
const entry = entries[plugin.id];
if (!entry || entry.enabled !== false) {
entries[plugin.id] = {
...entry,
enabled: false,
};
disabledIds.push(plugin.id);
const inferredPrevSlot = prevSlot ?? defaultSlotIdForKey(slotKey);
if (inferredPrevSlot && inferredPrevSlot !== params.selectedId) {
warnings.push(
`Exclusive slot "${slotKey}" switched from "${inferredPrevSlot}" to "${params.selectedId}".`,
);
}
const disabledIds: string[] = [];
if (params.registry) {
for (const plugin of params.registry.plugins) {
if (plugin.id === params.selectedId) {
continue;
}
const kindForSlot = (Object.keys(SLOT_BY_KIND) as PluginKind[]).find(
(k) => SLOT_BY_KIND[k] === slotKey,
);
if (!kindForSlot || !hasKind(plugin.kind, kindForSlot)) {
continue;
}
// Don't disable a plugin that still owns another slot (explicit or default).
const stillOwnsOtherSlot = (Object.keys(SLOT_BY_KIND) as PluginKind[])
.map((k) => SLOT_BY_KIND[k])
.filter((sk) => sk !== slotKey)
.some((sk) => (slots[sk] ?? defaultSlotIdForKey(sk)) === plugin.id);
if (stillOwnsOtherSlot) {
continue;
}
const entry = entries[plugin.id];
if (!entry || entry.enabled !== false) {
entries = {
...entries,
[plugin.id]: { ...entry, enabled: false },
};
disabledIds.push(plugin.id);
}
}
}
if (disabledIds.length > 0) {
warnings.push(
`Disabled other "${slotKey}" slot plugins: ${disabledIds.toSorted().join(", ")}.`,
);
}
if (prevSlot !== params.selectedId || disabledIds.length > 0) {
anyChanged = true;
}
}
if (disabledIds.length > 0) {
warnings.push(
`Disabled other "${slotKey}" slot plugins: ${disabledIds.toSorted().join(", ")}.`,
);
}
const changed = prevSlot !== params.selectedId || disabledIds.length > 0;
if (!changed) {
if (!anyChanged) {
return { config: params.config, warnings: [], changed: false };
}

View File

@ -1664,7 +1664,7 @@ export type OpenClawPluginDefinition = {
name?: string;
description?: string;
version?: string;
kind?: PluginKind;
kind?: PluginKind | PluginKind[];
configSchema?: OpenClawPluginConfigSchema;
register?: (api: OpenClawPluginApi) => void | Promise<void>;
activate?: (api: OpenClawPluginApi) => void | Promise<void>;