fix(ui): stabilize memory engine selection (#115750)

This commit is contained in:
Vincent Koc
2026-07-29 11:37:39 +02:00
committed by GitHub
parent 90ab06b631
commit 47c1921d5d
8 changed files with 191 additions and 9 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ import { definePluginEntry } from "openclaw/plugin-sdk/core";
export default definePluginEntry({
id: "memory-core",
name: "Memory (Core)",
name: "OpenClaw Memory",
description: "File-backed memory search tools and CLI",
register(api) {
api.registerCli(
+1 -1
View File
@@ -311,7 +311,7 @@ function registerMemoryManagerWarmup(
export default definePluginEntry({
id: "memory-core",
name: "Memory (Core)",
name: "OpenClaw Memory",
description: "File-backed memory search tools and CLI",
kind: "memory",
register(api) {
@@ -1,5 +1,6 @@
{
"id": "memory-core",
"name": "OpenClaw Memory",
"activation": {
"onStartup": false
},
@@ -0,0 +1,146 @@
// Control UI tests cover memory engine ordering and serialized config writes.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const uiProofArtifactDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"memory-settings-engine",
);
let browser: Browser;
let server: ControlUiE2eServer;
function configResponse(engineId: string, hash: string) {
const config = { plugins: { slots: { memory: engineId } } };
return {
config,
hash,
appliedConfigHash: hash,
issues: [],
raw: JSON.stringify(config),
valid: true,
};
}
const memoryPlugins = [
{
id: "memory-lancedb",
name: "Memory LanceDB",
installed: true,
enabled: true,
state: "enabled",
kind: ["memory"],
},
{
id: "memory-core",
name: "OpenClaw Memory",
installed: true,
enabled: true,
state: "enabled",
kind: ["memory"],
},
];
describeControlUiE2e("Control UI memory engine settings mocked Gateway E2E", () => {
beforeAll(async () => {
if (!chromiumAvailable) {
throw new Error(
`Playwright Chromium is not available at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
);
}
server = await startControlUiE2eServer();
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});
afterAll(async () => {
await browser?.close();
await server?.close();
});
it("keeps the default engine first and drains Off before selecting it", async () => {
const context = await browser.newContext({
colorScheme: "dark",
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1440 },
});
const page = await context.newPage();
const initialConfig = configResponse("memory-lancedb", "memory-hash-1");
const selectedConfig = configResponse("memory-core", "memory-hash-2");
const gateway = await installMockGateway(page, {
methodResponses: {
"config.get": initialConfig,
"plugins.list": { plugins: memoryPlugins, diagnostics: [], mutationAllowed: true },
},
});
try {
const response = await page.goto(`${server.baseUrl}settings/memory/settings`);
expect(response?.status()).toBe(200);
const engineGroup = page.locator("wa-radio-group.settings-segmented").first();
await engineGroup.waitFor();
await expect
.poll(async () =>
(await engineGroup.locator("wa-radio").allTextContents()).map((label) => label.trim()),
)
.toEqual(["OpenClaw Memory", "Memory LanceDB", "Off"]);
await gateway.deferNext("config.set");
await gateway.deferNext("plugins.setEnabled");
await engineGroup.getByRole("radio", { name: "Off", exact: true }).click();
const pendingOffSave = await gateway.waitForRequest("config.set");
expect(pendingOffSave.params).toMatchObject({ baseHash: "memory-hash-1" });
await engineGroup.getByRole("radio", { name: "OpenClaw Memory", exact: true }).click();
expect(await gateway.getRequests("plugins.setEnabled")).toHaveLength(0);
await gateway.resolveDeferred("config.set", { ok: true, hash: "mock-config-hash-1" });
const enableRequest = await gateway.waitForRequest("plugins.setEnabled");
expect(enableRequest.params).toEqual({ pluginId: "memory-core", enabled: true });
await gateway.setMethodResponse("config.get", selectedConfig);
await gateway.resolveDeferred("plugins.setEnabled", {
ok: true,
plugin: memoryPlugins[1],
restartRequired: false,
});
const selected = engineGroup.getByRole("radio", {
name: "OpenClaw Memory",
exact: true,
});
await expect.poll(() => selected.getAttribute("aria-checked")).toBe("true");
await expect.poll(() => page.getByText("Could not change the memory engine").count()).toBe(0);
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
await page
.locator(".settings-page > .settings-section")
.first()
.screenshot({
animations: "disabled",
path: path.join(uiProofArtifactDir, "01-openclaw-memory-selected.png"),
});
}
} finally {
await context.close();
}
});
});
+25 -4
View File
@@ -38,10 +38,10 @@ function memoryTabRoute(tab: "overview" | "memories" | "dreams" | "settings") {
return memoryRoute(`/settings/memory${tab === "overview" ? "" : `/${tab}`}`);
}
function engine(id: string, enabled: boolean): PluginCatalogItem {
function engine(id: string, enabled: boolean, name = id): PluginCatalogItem {
return {
id,
name: id,
name,
installed: true,
enabled,
state: enabled ? "enabled" : "disabled",
@@ -68,6 +68,7 @@ function createPage(params: {
catalog?: readonly PluginCatalogItem[];
mutationAllowed?: boolean;
patchForm?: (path: Array<string | number>, value: unknown) => void;
waitForPendingWrites?: () => Promise<void>;
setEnabled?: (pluginId: string, enabled: boolean) => Promise<unknown>;
refresh?: () => Promise<void>;
navigate?: (routeId: string, options?: ApplicationNavigationOptions) => void;
@@ -157,6 +158,7 @@ function createPage(params: {
),
patchForm: params.patchForm ?? vi.fn(),
removeFormValue: vi.fn(),
waitForPendingWrites: params.waitForPendingWrites ?? (() => Promise.resolve()),
refresh: vi.fn(params.refresh ?? (() => Promise.resolve())),
ensureLoaded: () => Promise.resolve(),
};
@@ -285,12 +287,22 @@ describe("MemorySettingsPage engine slot", () => {
// regardless of catalog enablement, so the page must not report lancedb.
const { element } = createPage({
configObject: {},
catalog: [engine("memory-core", false), engine("memory-lancedb", true)],
catalog: [
engine("memory-lancedb", true, "Memory LanceDB"),
engine("memory-core", false, "OpenClaw Memory"),
],
});
document.body.append(element);
try {
await waitForFast(() => expect(activeEngine(element)).toBe("memory-core"));
expect(element.textContent).toContain("falls back to its default owner");
expect(
[
...(element
.querySelector("wa-radio-group.settings-segmented")
?.querySelectorAll("wa-radio") ?? []),
].map((radio) => radio.textContent?.trim()),
).toEqual(["OpenClaw Memory", "Memory LanceDB", "Off"]);
} finally {
element.remove();
}
@@ -333,13 +345,15 @@ describe("MemorySettingsPage engine slot", () => {
}
});
it("persists Off as the none slot so it survives a config refresh", async () => {
it("persists Off and drains its autosave before re-enabling memory", async () => {
const pendingWrites = deferred<void>();
const patchForm = vi.fn();
const setEnabled = vi.fn(() => Promise.resolve({}));
const { element } = createPage({
configObject: { plugins: { slots: { memory: "memory-lancedb" } } },
catalog: [engine("memory-core", false), engine("memory-lancedb", true)],
patchForm,
waitForPendingWrites: () => pendingWrites.promise,
setEnabled,
});
document.body.append(element);
@@ -357,6 +371,13 @@ describe("MemorySettingsPage engine slot", () => {
await element.updateComplete;
expect(activeEngine(element)).toBe("");
expect(element.textContent).toContain("switched off");
selectEngine(element, "memory-core");
await Promise.resolve();
expect(setEnabled).not.toHaveBeenCalled();
pendingWrites.resolve();
await waitForFast(() => expect(setEnabled).toHaveBeenCalledOnce());
} finally {
element.remove();
}
+13 -1
View File
@@ -34,6 +34,7 @@ import { renderDreamingSettings, renderDreamingUnsupported } from "./memory-drea
import { renderMemoryOverview, type MemoryOverviewStatus } from "./memory-overview.ts";
import {
canonicalMemoryRouteLocation,
DEFAULT_MEMORY_ENGINE_ID,
memoryTabForRoute,
memorySchemaKeysForTab,
resolveMemoryBackend,
@@ -410,7 +411,14 @@ class MemorySettingsPage extends OpenClawLightDomElement {
return this.catalog.plugins
.filter(isMemoryEngine)
.map((plugin) => ({ id: plugin.id, label: plugin.name }))
.toSorted((left, right) => left.label.localeCompare(right.label));
.toSorted((left, right) => {
const leftIsDefault = left.id === DEFAULT_MEMORY_ENGINE_ID;
const rightIsDefault = right.id === DEFAULT_MEMORY_ENGINE_ID;
if (leftIsDefault !== rightIsDefault) {
return leftIsDefault ? -1 : 1;
}
return left.label.localeCompare(right.label);
});
}
private engineState(selection: MemoryEngineSelection): MemoryPluginState {
@@ -529,6 +537,10 @@ class MemorySettingsPage extends OpenClawLightDomElement {
this.engineBusy = true;
this.engineError = null;
try {
// `Off` is an autosaved form edit. Let it adopt its ack hash before the
// plugin RPC performs its own CAS write, or rapid Off -> engine changes
// can race each other and reject the second write as stale.
await this.context.runtimeConfig.waitForPendingWrites();
await setPluginEnabled(client, engineId, true);
await this.context.runtimeConfig.refresh();
await this.loadCatalog(client, connection);
+3 -1
View File
@@ -7,7 +7,7 @@
// from search would pull lit, hub-tabs, and settings-ui into the startup chunk.
import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce";
import type { RouteLocation } from "@openclaw/uirouter";
import { resolveSlotSelection } from "../../../../src/plugins/slots.ts";
import { defaultSlotIdForKey, resolveSlotSelection } from "../../../../src/plugins/slots.ts";
import { memoryTabFromPath, pathForMemoryTab, type MemoryRouteTab } from "../../app-route-paths.ts";
export type MemoryTab = MemoryRouteTab;
@@ -25,6 +25,8 @@ export type MemoryEngineSelection =
| { kind: "off" }
| { kind: "pinned"; engineId: string };
export const DEFAULT_MEMORY_ENGINE_ID = defaultSlotIdForKey("memory");
/** Scroll target for `memory.backend`, which Settings curates out of the editor. */
export const MEMORY_BACKEND_ANCHOR_ID = "memory-backend";
+1 -1
View File
@@ -20,7 +20,7 @@ function createProps(overrides: Partial<MemoryViewProps> = {}): MemoryViewProps
activeTab: "settings",
onTabChange: vi.fn(),
engineOptions: [
{ id: "memory-core", label: "Memory Core" },
{ id: "memory-core", label: "OpenClaw Memory" },
{ id: "memory-lancedb", label: "Memory LanceDB" },
],
engineSelection: { kind: "auto", engineId: "memory-core" },