diff --git a/extensions/memory-core/cli-metadata.ts b/extensions/memory-core/cli-metadata.ts index f2ab9a9416e7..e3aa32037da2 100644 --- a/extensions/memory-core/cli-metadata.ts +++ b/extensions/memory-core/cli-metadata.ts @@ -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( diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index 8b56d1e3518c..2820001f2672 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -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) { diff --git a/extensions/memory-core/openclaw.plugin.json b/extensions/memory-core/openclaw.plugin.json index a5e033f97e70..ebde90d63fd6 100644 --- a/extensions/memory-core/openclaw.plugin.json +++ b/extensions/memory-core/openclaw.plugin.json @@ -1,5 +1,6 @@ { "id": "memory-core", + "name": "OpenClaw Memory", "activation": { "onStartup": false }, diff --git a/ui/src/e2e/memory-settings-engine.e2e.test.ts b/ui/src/e2e/memory-settings-engine.e2e.test.ts new file mode 100644 index 000000000000..02b9d32c7c29 --- /dev/null +++ b/ui/src/e2e/memory-settings-engine.e2e.test.ts @@ -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(); + } + }); +}); diff --git a/ui/src/pages/config/memory-page.test.ts b/ui/src/pages/config/memory-page.test.ts index e82428977137..dc6237825945 100644 --- a/ui/src/pages/config/memory-page.test.ts +++ b/ui/src/pages/config/memory-page.test.ts @@ -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, value: unknown) => void; + waitForPendingWrites?: () => Promise; setEnabled?: (pluginId: string, enabled: boolean) => Promise; refresh?: () => Promise; 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(); 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(); } diff --git a/ui/src/pages/config/memory-page.ts b/ui/src/pages/config/memory-page.ts index 5fab5b15b6de..63d1d5bb574c 100644 --- a/ui/src/pages/config/memory-page.ts +++ b/ui/src/pages/config/memory-page.ts @@ -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); diff --git a/ui/src/pages/config/memory-schema.ts b/ui/src/pages/config/memory-schema.ts index 2b0ec4a027ed..5baf2c4c1e8b 100644 --- a/ui/src/pages/config/memory-schema.ts +++ b/ui/src/pages/config/memory-schema.ts @@ -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"; diff --git a/ui/src/pages/config/memory.test.ts b/ui/src/pages/config/memory.test.ts index 63ec5c6bbeba..84f167b0804f 100644 --- a/ui/src/pages/config/memory.test.ts +++ b/ui/src/pages/config/memory.test.ts @@ -20,7 +20,7 @@ function createProps(overrides: Partial = {}): 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" },