mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): stabilize memory engine selection (#115750)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user