From a3283af7c7086ac3dcdbb00a7f297a040b9a4a1e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Jul 2026 22:11:14 -0400 Subject: [PATCH] feat(ui): complete the Labs roster with the remaining experimental gates (#114233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): complete the Labs roster with the remaining experimental gates Labs shipped with two entries while three more experimental surfaces stayed reachable only by hand-editing config. Adds Tool Search, lean local-model tools, and message audit metadata, each with the subtext and documentation link the page already promises. Two of them do not fit the boolean gate the registry assumed, so the registry now states each row's on/off values instead of hardcoding true/false: - Tool Search is a mode, and resolveToolSearchConfig defaults an unset mode to "code" even in object form. A bare enable would therefore select the surface with the weakest recall rather than the bounded directory this row advertises, so enabling writes `mode: "directory"` alongside the gate in one patch. - Message audit is `off | direct | all`. Labs offers the conservative `direct`, so turning it on cannot start recording group or unknown conversations the operator never opted into, and `all` deliberately does not read as on — that is a broader choice made elsewhere and this row must not quietly narrow it. The audit row carries a restart hint because startGatewayEventSubscriptions resolves the mode once and bakes it into the recorder, which outlives the reload plan's `logging: none` rule. The other four are read per agent run. * fix(ui): read a broader audit mode as enabled instead of narrowing it `all` records more than the `direct` this row offers, but it is still on. Strict equality against onValue rendered it as off, which made the switch look available and would have silently rewritten a deliberately broader operator setting down to `direct` on the next click. Separate "what enabling writes" from "what counts as enabled": onValue stays the value Labs sets, activeValues lists every value that reads as on. Turning the row off from `all` now writes `off`, which is the only narrowing the operator asked for. The test that covered this asserted the opposite of what its own comment described; corrected, plus coverage for the off-from-all write. * fix(ui): mirror the runtime rule for Tool Search enablement ToolSearchConfig is a union, and resolveToolSearchConfig treats an object that configures anything besides `enabled` as already on: `readBoolean(raw.enabled, configured)`. Reading only the `enabled` leaf showed `{ mode: "tools" }` as off, so the row offered a switch that would have replaced that operator's mode with `directory` — the same narrowing the audit row was just fixed for. Give the registry a `readEnabled` override for gates whose enablement is not a single leaf, and point Tool Search at a copy of the runtime rule with the resolver named so the two stay comparable. The other four rows keep the leaf read and declare `readEnabled: null` explicitly. * chore(ui): keep LabFeatureValue local to the registry Only the registry names the type, and the hard-zero Knip production scan rejects an export with no production consumer outside its own module. --- ui/src/i18n/locales/en.ts | 15 +++ ui/src/pages/labs/labs-page.test.ts | 136 +++++++++++++++++++++++++--- ui/src/pages/labs/labs-registry.ts | 118 +++++++++++++++++++++++- 3 files changed, 255 insertions(+), 14 deletions(-) diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 0eaddbe36670..1ee8e4c4299a 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2407,6 +2407,21 @@ export const en: TranslationMap = { empty: "No active swarms.", defaultPhase: "Unphased", }, + toolSearch: { + title: "Tool Search", + description: + "Keep a bounded tool directory visible and defer the rest behind search, so large MCP and plugin catalogs stop crowding the prompt.", + }, + localModelLean: { + title: "Lean tools for local models", + description: + "Drop heavyweight default tools that smaller local models handle poorly, leaving a shorter set they can use reliably.", + }, + auditMessages: { + title: "Message audit metadata", + description: + "Record content-free metadata for direct conversations in the audit ledger. Message content is never stored.", + }, }, aboutPage: { productName: "OpenClaw", diff --git a/ui/src/pages/labs/labs-page.test.ts b/ui/src/pages/labs/labs-page.test.ts index 5fd279cce371..f19e4314682d 100644 --- a/ui/src/pages/labs/labs-page.test.ts +++ b/ui/src/pages/labs/labs-page.test.ts @@ -7,6 +7,7 @@ import { createApplicationContextProvider, type ApplicationContextProvider, } from "../../test-helpers/application-context.ts"; +import { LAB_FEATURES } from "./labs-registry.ts"; import "./labs-page.ts"; type LabsPageElement = HTMLElement & { updateComplete: Promise }; @@ -81,27 +82,19 @@ describe("LabsPage", () => { vi.restoreAllMocks(); }); - it("renders the experimental Code Mode and Swarm entries", async () => { + it("renders every registered experimental entry with its documentation link", async () => { const { page } = await mountPage({ tools: { codeMode: { enabled: true }, swarm: { enabled: true } }, }); expect(page.querySelector(".settings-page__intro")?.textContent).toContain("experimental"); - expect(page.querySelectorAll(".settings-row")).toHaveLength(2); + expect(page.querySelectorAll(".settings-row")).toHaveLength(LAB_FEATURES.length); expect(page.textContent).toContain("Code Mode"); expect(page.textContent).toContain("Swarm"); - expect(page.textContent).not.toContain("restart required"); expect(codeModeToggle(page).checked).toBe(true); - expect([...page.querySelectorAll("wa-switch")]).toEqual([ - expect.objectContaining({ checked: true }), - expect.objectContaining({ checked: true }), - ]); const docs = [...page.querySelectorAll(".settings-row__desc a")]; - expect(docs.map((link) => link.href)).toEqual([ - "https://docs.openclaw.ai/tools/code-mode", - "https://docs.openclaw.ai/tools/swarm", - ]); + expect(docs.map((link) => link.href)).toEqual(LAB_FEATURES.map((feature) => feature.docsUrl)); expect(docs.every((link) => link.target === "_blank")).toBe(true); expect(docs.every((link) => link.rel.includes("noopener"))).toBe(true); }); @@ -144,6 +137,32 @@ describe("LabsPage", () => { expectedPatch: { tools: { swarm: { enabled: true } } }, note: "labs: update swarm", }, + { + // Enabling must pin the mode: resolveToolSearchConfig defaults an unset + // mode to "code", so a bare `enabled: true` would select the surface with + // the weakest recall rather than the one this row advertises. + label: "Tool Search", + index: 2, + sourceConfig: { tools: { toolSearch: { enabled: false } } }, + expectedPatch: { tools: { toolSearch: { enabled: true, mode: "directory" } } }, + note: "labs: update toolSearch", + }, + { + label: "Lean tools for local models", + index: 3, + sourceConfig: {}, + expectedPatch: { agents: { defaults: { experimental: { localModelLean: true } } } }, + note: "labs: update localModelLean", + }, + { + // Not a boolean gate: the on state is the conservative `direct` mode, so + // enabling here cannot start recording group or unknown conversations. + label: "Message audit metadata", + index: 4, + sourceConfig: { logging: { audit: { messages: "off" } } }, + expectedPatch: { logging: { audit: { messages: "direct" } } }, + note: "labs: update auditMessages", + }, ])("writes true at the registered config path when enabling $label", async (testCase) => { const { page, runtimeConfig } = await mountPage(testCase.sourceConfig); const toggle = labToggle(page, testCase.index, testCase.label); @@ -157,4 +176,99 @@ describe("LabsPage", () => { note: testCase.note, }); }); + + it("reads a mode-valued gate as on only for the mode this row offers", async () => { + const auditIndex = LAB_FEATURES.findIndex((feature) => feature.id === "auditMessages"); + + const off = await mountPage({ logging: { audit: { messages: "off" } } }); + expect(labToggle(off.page, auditIndex, "audit").checked).toBe(false); + off.provider.remove(); + + const direct = await mountPage({ logging: { audit: { messages: "direct" } } }); + expect(labToggle(direct.page, auditIndex, "audit").checked).toBe(true); + direct.provider.remove(); + + // `all` is broader than the mode this row offers, but it is still on. Showing + // it as off would make the switch look available and quietly narrow a choice + // the operator made deliberately somewhere else. + const all = await mountPage({ logging: { audit: { messages: "all" } } }); + expect(labToggle(all.page, auditIndex, "audit").checked).toBe(true); + }); + + it("turns a broader audit mode off rather than narrowing it", async () => { + const auditIndex = LAB_FEATURES.findIndex((feature) => feature.id === "auditMessages"); + const { page, runtimeConfig } = await mountPage({ + logging: { audit: { messages: "all" } }, + }); + const toggle = labToggle(page, auditIndex, "audit"); + + toggle.checked = false; + toggle.dispatchEvent(new Event("change", { bubbles: true, composed: true })); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { logging: { audit: { messages: "off" } } }, + note: "labs: update auditMessages", + }); + }); + + it("marks only the startup-scoped entry as needing a restart", async () => { + const { page } = await mountPage({}); + const rows = [...page.querySelectorAll(".settings-row")]; + + const restartRows = rows.filter((row) => row.textContent?.includes("restart")); + expect(restartRows).toHaveLength(1); + expect(restartRows[0]?.textContent).toContain("Message audit metadata"); + }); +}); + +describe("LabsPage tool search enablement", () => { + const toolSearchIndex = LAB_FEATURES.findIndex((feature) => feature.id === "toolSearch"); + + // readToolSearchConfig + readBoolean(raw.enabled, configured): an object that + // configures anything besides `enabled` is already on at runtime. + it.each([ + { label: "boolean shorthand", config: { tools: { toolSearch: true } }, expected: true }, + { + label: "explicit enabled", + config: { tools: { toolSearch: { enabled: true } } }, + expected: true, + }, + { + label: "mode without enabled", + config: { tools: { toolSearch: { mode: "tools" } } }, + expected: true, + }, + { + label: "explicit disabled", + config: { tools: { toolSearch: { enabled: false } } }, + expected: false, + }, + { label: "boolean false", config: { tools: { toolSearch: false } }, expected: false }, + { label: "unset", config: {}, expected: false }, + ])("reads $label as $expected", async ({ config, expected }) => { + const { page, provider } = await mountPage(config); + + expect(labToggle(page, toolSearchIndex, "Tool Search").checked).toBe(expected); + provider.remove(); + }); + + it("does not replace an operator's existing mode when already on", async () => { + const { page, runtimeConfig } = await mountPage({ + tools: { toolSearch: { mode: "tools" } }, + }); + const toggle = labToggle(page, toolSearchIndex, "Tool Search"); + + // The row reads as on, so the only move available is turning it off — it + // cannot be clicked into overwriting `tools` with `directory`. + expect(toggle.checked).toBe(true); + toggle.checked = false; + toggle.dispatchEvent(new Event("change", { bubbles: true, composed: true })); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { tools: { toolSearch: { enabled: false } } }, + note: "labs: update toolSearch", + }); + }); }); diff --git a/ui/src/pages/labs/labs-registry.ts b/ui/src/pages/labs/labs-registry.ts index 3f16038d2c58..c740156b151a 100644 --- a/ui/src/pages/labs/labs-registry.ts +++ b/ui/src/pages/labs/labs-registry.ts @@ -1,11 +1,42 @@ import { t } from "../../i18n/index.ts"; +/** What a lab row writes at its gate. Most gates are booleans; some are modes. */ +type LabFeatureValue = boolean | string; + export type LabFeature = { id: string; title: () => string; description: () => string; docsUrl: string; + /** Leaf whose value decides whether the row reads as on. */ configPath: readonly [string, ...string[]]; + /** + * Values written at `configPath`. Required rather than defaulted to `true` and + * `false` so a setting that spells its on/off state as a mode has to say so + * here instead of silently writing a boolean the runtime would ignore. + */ + onValue: LabFeatureValue; + offValue: LabFeatureValue; + /** + * Every value that reads as on, which is not always just `onValue`. A mode can + * have settings broader than the one Labs offers, and those must render as + * enabled — otherwise the row shows off, and clicking it narrows a choice the + * operator made deliberately somewhere else. + */ + activeValues: readonly LabFeatureValue[]; + /** + * Replaces the leaf read when the runtime decides enablement from more than + * one key. Receives the value at the gate's parent, which may be the boolean + * shorthand. Must mirror the runtime resolver it cites, or the row will + * misreport a config the runtime considers on. + */ + readEnabled: ((raw: unknown) => boolean) | null; + /** + * Extra keys written beside the gate when enabling, relative to the gate's + * parent. Labs pins the variant we actually recommend rather than inheriting + * whatever a bare enable defaults to. + */ + enableAlso: Readonly> | null; restartHint: (() => string) | null; }; @@ -16,6 +47,11 @@ export const LAB_FEATURES = [ description: () => t("labsPage.codeMode.description"), docsUrl: "https://docs.openclaw.ai/tools/code-mode", configPath: ["tools", "codeMode", "enabled"], + onValue: true, + offValue: false, + activeValues: [true], + readEnabled: null, + enableAlso: null, restartHint: null, }, { @@ -24,8 +60,75 @@ export const LAB_FEATURES = [ description: () => t("labsPage.swarm.description"), docsUrl: "https://docs.openclaw.ai/tools/swarm", configPath: ["tools", "swarm", "enabled"], + onValue: true, + offValue: false, + activeValues: [true], + readEnabled: null, + enableAlso: null, restartHint: null, }, + { + id: "toolSearch", + title: () => t("labsPage.toolSearch.title"), + description: () => t("labsPage.toolSearch.description"), + docsUrl: "https://docs.openclaw.ai/tools/tool-search", + configPath: ["tools", "toolSearch", "enabled"], + onValue: true, + offValue: false, + activeValues: [true], + // Mirrors resolveToolSearchConfig: the boolean shorthand decides directly, + // and an object configuring anything besides `enabled` is already on. + // Reading only the `enabled` leaf would show `{ mode: "tools" }` as off and + // let a click replace that operator's mode with ours. + readEnabled: (raw) => { + if (typeof raw === "boolean") { + return raw; + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return false; + } + const node = raw as Record; + return typeof node.enabled === "boolean" + ? node.enabled + : Object.keys(node).some((key) => key !== "enabled"); + }, + // resolveToolSearchConfig defaults an unset mode to "code" even in object + // form, which is the surface with the weakest recall. Pin the bounded + // directory instead, so enabling from Labs is the variant we recommend. + enableAlso: { mode: "directory" }, + restartHint: null, + }, + { + id: "localModelLean", + title: () => t("labsPage.localModelLean.title"), + description: () => t("labsPage.localModelLean.description"), + docsUrl: "https://docs.openclaw.ai/gateway/local-models", + configPath: ["agents", "defaults", "experimental", "localModelLean"], + onValue: true, + offValue: false, + activeValues: [true], + readEnabled: null, + enableAlso: null, + restartHint: null, + }, + { + id: "auditMessages", + title: () => t("labsPage.auditMessages.title"), + description: () => t("labsPage.auditMessages.description"), + docsUrl: "https://docs.openclaw.ai/gateway/audit", + // Not a boolean: `off` | `direct` | `all`. Labs offers the conservative + // `direct`, so turning it on cannot start recording group or unknown + // conversations that the operator never opted into. + configPath: ["logging", "audit", "messages"], + onValue: "direct", + offValue: "off", + activeValues: ["direct", "all"], + readEnabled: null, + enableAlso: null, + // startGatewayEventSubscriptions resolves the mode once and bakes it into + // the recorder, so this outlives the reload plan's `logging: none` rule. + restartHint: () => t("labsPage.restartRequired"), + }, ] as const satisfies readonly LabFeature[]; function recordAtPath(config: Record, path: readonly string[]): unknown { @@ -49,6 +152,9 @@ export function isLabFeatureEnabled( const parentPath = feature.configPath.slice(0, -1); const key = feature.configPath.at(-1); const parent = recordAtPath(config, parentPath); + if (feature.readEnabled) { + return feature.readEnabled(parent); + } // Feature gates accept the shipped boolean shorthand as well as the object // form. A registry path ending in `enabled` must reflect either shape. if (key === "enabled" && typeof parent === "boolean") { @@ -57,15 +163,21 @@ export function isLabFeatureEnabled( if (!parent || typeof parent !== "object" || Array.isArray(parent) || !key) { return false; } - return (parent as Record)[key] === true; + return feature.activeValues.includes((parent as Record)[key] as LabFeatureValue); } export function labFeatureMergePatch( feature: LabFeature, enabled: boolean, ): Record { - let patch: unknown = enabled; - for (const segment of feature.configPath.toReversed()) { + const key = feature.configPath.at(-1) as string; + // Companion keys ride in the same patch as the gate so one save cannot leave + // the feature on in a variant Labs never offered. + let patch: unknown = { + [key]: enabled ? feature.onValue : feature.offValue, + ...(enabled ? feature.enableAlso : null), + }; + for (const segment of feature.configPath.slice(0, -1).toReversed()) { patch = { [segment]: patch }; } return patch as Record;