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;