From 235908c30e1b67f704fdd4ae01dea4e1ba94a883 Mon Sep 17 00:00:00 2001 From: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:36:48 -0600 Subject: [PATCH] fix: support multi-kind plugins for dual slot ownership (#57507) (thanks @fuller-stack-dev) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- src/agents/skills/plugin-skills.ts | 3 +- src/config/validation.ts | 3 +- src/hooks/plugin-hooks.ts | 3 +- src/plugins/config-state.test.ts | 55 ++++++ src/plugins/config-state.ts | 28 +-- src/plugins/loader.ts | 29 +-- src/plugins/manifest-registry.ts | 2 +- src/plugins/manifest.ts | 14 +- .../registry.dual-kind-memory-gate.test.ts | 95 ++++++++++ src/plugins/registry.ts | 69 +++++++- src/plugins/slots.test.ts | 165 +++++++++++++++++- src/plugins/slots.ts | 136 ++++++++++----- src/plugins/types.ts | 2 +- 13 files changed, 520 insertions(+), 84 deletions(-) create mode 100644 src/plugins/registry.dual-kind-memory-gate.test.ts diff --git a/src/agents/skills/plugin-skills.ts b/src/agents/skills/plugin-skills.ts index 5a02737e5cd..c7a371ad689 100644 --- a/src/agents/skills/plugin-skills.ts +++ b/src/agents/skills/plugin-skills.ts @@ -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) { diff --git a/src/config/validation.ts b/src/config/validation.ts index 9324e7669b8..fc70f5b5775 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -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; } } diff --git a/src/hooks/plugin-hooks.ts b/src/hooks/plugin-hooks.ts index c6651ff560b..0f53fb61bca 100644 --- a/src/hooks/plugin-hooks.ts +++ b/src/hooks/plugin-hooks.ts @@ -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; } diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 1da249b2b3f..a6e1a7fc7fb 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -3,6 +3,7 @@ import { normalizePluginsConfig, resolveEffectiveEnableState, resolveEnableState, + resolveMemorySlotDecision, } from "./config-state.js"; function normalizeVoiceCallEntry(entry: Record) { @@ -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); + }); +}); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 9d5d4217c68..67f3776aabd 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -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 }; } diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index efe10d34728..f608c8b213a 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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") { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 75cbc3981af..4ff0df52211 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -49,7 +49,7 @@ export type PluginManifestRecord = { format?: PluginFormat; bundleFormat?: PluginBundleFormat; bundleCapabilities?: string[]; - kind?: PluginKind; + kind?: PluginKind | PluginKind[]; channels: string[]; providers: string[]; cliBackends: string[]; diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index b5cdf1b58f6..69932180a5e 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -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( diff --git a/src/plugins/registry.dual-kind-memory-gate.test.ts b/src/plugins/registry.dual-kind-memory-gate.test.ts new file mode 100644 index 00000000000..f878ef881f4 --- /dev/null +++ b/src/plugins/registry.dual-kind-memory-gate.test.ts @@ -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(); + }); +}); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 958520816fc..a35aaa8834b 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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; configJsonSchema?: Record; + 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 diff --git a/src/plugins/slots.test.ts b/src/plugins/slots.test.ts index f4884b40b4a..d64e794810e 100644 --- a/src/plugins/slots.test.ts +++ b/src/plugins/slots.test.ts @@ -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); + }); }); diff --git a/src/plugins/slots.ts b/src/plugins/slots.ts index bcbbdd44a03..5aea34eedd1 100644 --- a/src/plugins/slots.ts +++ b/src/plugins/slots.ts @@ -6,7 +6,7 @@ export type PluginSlotKey = keyof PluginSlotsConfig; type SlotPluginRecord = { id: string; - kind?: PluginKind; + kind?: PluginKind | PluginKind[]; }; const SLOT_BY_KIND: Record = { @@ -19,6 +19,26 @@ const DEFAULT_SLOT_BY_KEY: Record = { 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 }; } diff --git a/src/plugins/types.ts b/src/plugins/types.ts index edfc7f9e965..a9f81c8f1c4 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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; activate?: (api: OpenClawPluginApi) => void | Promise;