fix(plugins): reset context engine slot on uninstall

This commit is contained in:
Peter Steinberger
2026-04-26 09:49:53 +01:00
parent 42487d0dac
commit c6b7444d16
8 changed files with 84 additions and 8 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
- Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd. - Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd.
- CLI/update: run package post-update doctor with `--fix` so package updates repair config migrations before restart. Thanks @shakkernerd. - CLI/update: run package post-update doctor with `--fix` so package updates repair config migrations before restart. Thanks @shakkernerd.
- CLI/update: retry failed npm global updates with `--omit=optional` and ignore the superseded first failure when the fallback succeeds. Thanks @shakkernerd. - CLI/update: retry failed npm global updates with `--omit=optional` and ignore the superseded first failure when the fallback succeeds. Thanks @shakkernerd.
- Plugins/uninstall: migrate and reset `plugins.slots.contextEngine` alongside memory slots when plugin ids change or selected plugins are removed. Thanks @shakkernerd.
- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex. - Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex.
- Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc. - Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc.
- Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc. - Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc.

View File

@@ -594,6 +594,7 @@ export function resetPluginsCliTestState() {
allowlist: false, allowlist: false,
loadPath: false, loadPath: false,
memorySlot: false, memorySlot: false,
contextEngineSlot: false,
directory: false, directory: false,
}, },
}); });

View File

@@ -634,6 +634,11 @@ export function registerPluginsCli(program: Command) {
if (cfg.plugins?.slots?.memory === pluginId) { if (cfg.plugins?.slots?.memory === pluginId) {
preview.push(`memory slot (will reset to "${defaultSlotIdForKey("memory")}")`); preview.push(`memory slot (will reset to "${defaultSlotIdForKey("memory")}")`);
} }
if (cfg.plugins?.slots?.contextEngine === pluginId) {
preview.push(
`context engine slot (will reset to "${defaultSlotIdForKey("contextEngine")}")`,
);
}
const channelIds = plugin?.status === "loaded" ? plugin.channelIds : undefined; const channelIds = plugin?.status === "loaded" ? plugin.channelIds : undefined;
const channels = cfg.channels as Record<string, unknown> | undefined; const channels = cfg.channels as Record<string, unknown> | undefined;
if (hasInstall && channels) { if (hasInstall && channels) {
@@ -723,6 +728,9 @@ export function registerPluginsCli(program: Command) {
if (result.actions.memorySlot) { if (result.actions.memorySlot) {
removed.push("memory slot"); removed.push("memory slot");
} }
if (result.actions.contextEngineSlot) {
removed.push("context engine slot");
}
if (result.actions.channelConfig) { if (result.actions.channelConfig) {
removed.push("channel config"); removed.push("channel config");
} }

View File

@@ -40,6 +40,9 @@ describe("plugins cli uninstall", () => {
installPath: ALPHA_INSTALL_PATH, installPath: ALPHA_INSTALL_PATH,
}, },
}, },
slots: {
contextEngine: "alpha",
},
}, },
} as OpenClawConfig); } as OpenClawConfig);
buildPluginDiagnosticsReport.mockReturnValue({ buildPluginDiagnosticsReport.mockReturnValue({
@@ -53,6 +56,7 @@ describe("plugins cli uninstall", () => {
expect(writeConfigFile).not.toHaveBeenCalled(); expect(writeConfigFile).not.toHaveBeenCalled();
expect(refreshPluginRegistry).not.toHaveBeenCalled(); expect(refreshPluginRegistry).not.toHaveBeenCalled();
expect(runtimeLogs.some((line) => line.includes("Dry run, no changes made."))).toBe(true); expect(runtimeLogs.some((line) => line.includes("Dry run, no changes made."))).toBe(true);
expect(runtimeLogs.some((line) => line.includes("context engine slot"))).toBe(true);
}); });
it("uninstalls with --force and --keep-files without prompting", async () => { it("uninstalls with --force and --keep-files without prompting", async () => {
@@ -93,6 +97,7 @@ describe("plugins cli uninstall", () => {
allowlist: false, allowlist: false,
loadPath: false, loadPath: false,
memorySlot: false, memorySlot: false,
contextEngineSlot: false,
directory: false, directory: false,
}, },
}); });
@@ -162,6 +167,7 @@ describe("plugins cli uninstall", () => {
allowlist: false, allowlist: false,
loadPath: false, loadPath: false,
memorySlot: false, memorySlot: false,
contextEngineSlot: false,
directory: false, directory: false,
}, },
}); });

View File

@@ -365,6 +365,22 @@ describe("removePluginFromConfig", () => {
expect(actions.memorySlot).toBe(expectedChanged); expect(actions.memorySlot).toBe(expectedChanged);
}); });
it("clears context engine slot when uninstalling active context engine plugin", () => {
const config = createPluginConfig({
entries: {
"context-plugin": { enabled: true },
},
slots: {
contextEngine: "context-plugin",
},
});
const { config: result, actions } = removePluginFromConfig(config, "context-plugin");
expect(result.plugins?.slots?.contextEngine).toBe("legacy");
expect(actions.contextEngineSlot).toBe(true);
});
it("removes plugins object when uninstall leaves only empty slots", () => { it("removes plugins object when uninstall leaves only empty slots", () => {
const config = createSinglePluginWithEmptySlotsConfig(); const config = createSinglePluginWithEmptySlotsConfig();

View File

@@ -13,6 +13,7 @@ export type UninstallActions = {
allowlist: boolean; allowlist: boolean;
loadPath: boolean; loadPath: boolean;
memorySlot: boolean; memorySlot: boolean;
contextEngineSlot: boolean;
channelConfig: boolean; channelConfig: boolean;
directory: boolean; directory: boolean;
}; };
@@ -155,6 +156,7 @@ export function removePluginFromConfig(
allowlist: false, allowlist: false,
loadPath: false, loadPath: false,
memorySlot: false, memorySlot: false,
contextEngineSlot: false,
channelConfig: false, channelConfig: false,
}; };
@@ -204,7 +206,7 @@ export function removePluginFromConfig(
} }
} }
// Reset memory slot if this plugin was selected // Reset slots if this plugin was selected.
let slots = pluginsConfig.slots; let slots = pluginsConfig.slots;
if (slots?.memory === pluginId) { if (slots?.memory === pluginId) {
slots = { slots = {
@@ -213,6 +215,13 @@ export function removePluginFromConfig(
}; };
actions.memorySlot = true; actions.memorySlot = true;
} }
if (slots?.contextEngine === pluginId) {
slots = {
...slots,
contextEngine: defaultSlotIdForKey("contextEngine"),
};
actions.contextEngineSlot = true;
}
if (slots && Object.keys(slots).length === 0) { if (slots && Object.keys(slots).length === 0) {
slots = undefined; slots = undefined;
} }

View File

@@ -876,6 +876,41 @@ describe("updateNpmInstalledPlugins", () => {
expect(result.config.plugins?.installs?.["voice-call"]).toBeUndefined(); expect(result.config.plugins?.installs?.["voice-call"]).toBeUndefined();
}); });
it("migrates context engine slot when a plugin id changes during update", async () => {
installPluginFromNpmSpecMock.mockResolvedValue({
ok: true,
pluginId: "@openclaw/context-engine",
targetDir: "/tmp/openclaw-context-engine",
version: "0.0.2",
extensions: ["index.ts"],
});
const result = await updateNpmInstalledPlugins({
config: {
plugins: {
slots: { contextEngine: "context-engine" },
installs: {
"context-engine": {
source: "npm",
spec: "@openclaw/context-engine",
installPath: "/tmp/context-engine",
},
},
},
} as OpenClawConfig,
pluginIds: ["context-engine"],
});
expect(result.config.plugins?.slots?.contextEngine).toBe("@openclaw/context-engine");
expect(result.config.plugins?.installs?.["@openclaw/context-engine"]).toMatchObject({
source: "npm",
spec: "@openclaw/context-engine",
installPath: "/tmp/openclaw-context-engine",
version: "0.0.2",
});
expect(result.config.plugins?.installs?.["context-engine"]).toBeUndefined();
});
it("checks marketplace installs during dry-run updates", async () => { it("checks marketplace installs during dry-run updates", async () => {
installPluginFromMarketplaceMock.mockResolvedValue({ installPluginFromMarketplaceMock.mockResolvedValue({
ok: true, ok: true,

View File

@@ -417,13 +417,13 @@ function migratePluginConfigId(cfg: OpenClawConfig, fromId: string, toId: string
delete nextEntries[fromId]; delete nextEntries[fromId];
} }
const nextSlots = const nextSlots = slots
slots?.memory === fromId ? {
? { ...slots,
...slots, ...(slots.memory === fromId ? { memory: toId } : {}),
memory: toId, ...(slots.contextEngine === fromId ? { contextEngine: toId } : {}),
} }
: slots; : undefined;
return { return {
...cfg, ...cfg,