mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(ui): native command settings no longer require Raw mode (#121832)
* fix(ui): render native command settings controls Render boolean-or-auto command settings as safe On/Off/Auto controls instead of forcing Raw mode, while preserving typed config values and fail-closed handling for unsafe unions. * fix(ui): preserve oneOf union exclusivity
This commit is contained in:
committed by
GitHub
parent
19ed7feee1
commit
ee7fae8a90
@@ -650,6 +650,7 @@ function buildConfigMocks(options: { swarmEnabled?: boolean } = {}) {
|
||||
messages: { queueLimit: 5, responsePrefix: "" },
|
||||
gateway: { port: 18789, bind: "127.0.0.1" },
|
||||
agents: { defaults: { thinkingDefault: "medium" } },
|
||||
commands: { native: "auto", nativeSkills: "auto" },
|
||||
models: { mode: "merge" },
|
||||
...(options.swarmEnabled ? { tools: { swarm: true } } : {}),
|
||||
channels: {
|
||||
@@ -743,6 +744,22 @@ function buildConfigMocks(options: { swarmEnabled?: boolean } = {}) {
|
||||
},
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
type: "object",
|
||||
title: "Commands",
|
||||
properties: {
|
||||
native: {
|
||||
title: "Native Commands",
|
||||
default: "auto",
|
||||
anyOf: [{ type: "boolean" }, { type: "string", const: "auto" }],
|
||||
},
|
||||
nativeSkills: {
|
||||
title: "Native Skill Commands",
|
||||
default: "auto",
|
||||
anyOf: [{ type: "boolean" }, { type: "string", const: "auto" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
type: "object",
|
||||
title: "Gateway",
|
||||
|
||||
@@ -125,4 +125,19 @@ describe("generated config schema Control UI contract", () => {
|
||||
|
||||
expect([...new Set(lossyPaths)].toSorted()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps native command settings editable as tri-state controls", () => {
|
||||
const rawSchema = computeBaseConfigSchemaResponse({
|
||||
generatedAt: "control-ui-contract",
|
||||
}).schema as JsonSchema;
|
||||
const analysis = analyzeConfigSchema(rawSchema);
|
||||
|
||||
for (const path of ["commands.native", "commands.nativeSkills"]) {
|
||||
expect(isPathUnsupported(path, analysis.unsupportedPaths)).toBe(false);
|
||||
expect(analysis.schema ? schemaAtPath(analysis.schema, path) : undefined).toMatchObject({
|
||||
enum: [true, false, "auto"],
|
||||
default: "auto",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,26 +37,103 @@ describe("config form composition integrity", () => {
|
||||
expect(unsupportedUnion.unsupportedPaths).toEqual(["mixed"]);
|
||||
});
|
||||
|
||||
it("keeps literal and typed unions in Raw mode", () => {
|
||||
it("renders finite boolean unions while keeping open typed unions in Raw mode", () => {
|
||||
const analysis = analyzeConfigSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
retention: {
|
||||
anyOf: [{ type: "string" }, { const: false }],
|
||||
},
|
||||
guarded: {
|
||||
anyOf: [{ type: "boolean", not: { const: true } }, { const: "auto" }],
|
||||
},
|
||||
nullableBoolean: {
|
||||
anyOf: [{ type: ["boolean", "null"] }, { const: "auto" }],
|
||||
},
|
||||
ambiguousBooleanLabel: {
|
||||
anyOf: [{ type: "boolean" }, { const: "true" }],
|
||||
},
|
||||
overlappingOneOf: {
|
||||
oneOf: [{ type: "boolean" }, { const: true }],
|
||||
},
|
||||
overlappingAnyOf: {
|
||||
anyOf: [{ type: "boolean" }, { const: true }],
|
||||
},
|
||||
mode: {
|
||||
oneOf: [{ type: "boolean" }, { enum: ["auto", "manual"] }],
|
||||
title: "Native Commands",
|
||||
default: "auto",
|
||||
anyOf: [{ type: "boolean" }, { type: "string", const: "auto" }],
|
||||
},
|
||||
plainMode: {
|
||||
title: "Plain Mode",
|
||||
enum: ["auto", "manual"],
|
||||
},
|
||||
disjointOneOf: {
|
||||
oneOf: [{ type: "boolean" }, { const: "auto" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(analysis.unsupportedPaths).toEqual(["retention", "mode"]);
|
||||
expect(analysis.unsupportedPaths).toEqual([
|
||||
"retention",
|
||||
"guarded",
|
||||
"nullableBoolean",
|
||||
"ambiguousBooleanLabel",
|
||||
"overlappingOneOf",
|
||||
]);
|
||||
expect(analysis.schema?.properties?.retention).toMatchObject({
|
||||
anyOf: [{ type: "string" }, { const: false }],
|
||||
});
|
||||
expect(analysis.schema?.properties?.mode).toMatchObject({
|
||||
oneOf: [{ type: "boolean" }, { enum: ["auto", "manual"] }],
|
||||
enum: [true, false, "auto"],
|
||||
default: "auto",
|
||||
});
|
||||
expect(analysis.schema?.properties?.overlappingOneOf).toMatchObject({
|
||||
oneOf: [{ type: "boolean" }, { const: true }],
|
||||
});
|
||||
expect(analysis.schema?.properties?.overlappingAnyOf).toMatchObject({
|
||||
enum: [true, false],
|
||||
});
|
||||
expect(analysis.schema?.properties?.disjointOneOf).toMatchObject({
|
||||
enum: [true, false, "auto"],
|
||||
});
|
||||
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { retention: "30d", mode: "auto", plainMode: "auto" },
|
||||
showAdvanced: true,
|
||||
onShowAdvanced: () => {},
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const modeControl = [
|
||||
...container.querySelectorAll<HTMLElement & { value: string }>(
|
||||
"wa-radio-group.settings-segmented",
|
||||
),
|
||||
].find((group) => group.querySelector("[slot='label']")?.textContent === "Native Commands");
|
||||
expect(modeControl).not.toBeNull();
|
||||
const modeOptions = [...(modeControl?.querySelectorAll("wa-radio") ?? [])];
|
||||
// Web Awesome radios take their accessible names from their visible default-slot text.
|
||||
expect(modeOptions.map((option) => option.textContent?.trim())).toEqual(["On", "Off", "Auto"]);
|
||||
expect(modeOptions.map((option) => option.getAttribute("value"))).toEqual(["0", "1", "2"]);
|
||||
const plainModeControl = [...container.querySelectorAll("wa-radio-group")].find(
|
||||
(group) => group.querySelector("[slot='label']")?.textContent === "Plain Mode",
|
||||
);
|
||||
expect(
|
||||
[...(plainModeControl?.querySelectorAll("wa-radio") ?? [])].map((option) =>
|
||||
option.textContent?.trim(),
|
||||
),
|
||||
).toEqual(["auto", "manual"]);
|
||||
modeControl!.value = "1";
|
||||
modeControl!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["mode"], false);
|
||||
});
|
||||
|
||||
it("marks required-only object branches as form-unsafe", () => {
|
||||
|
||||
@@ -569,6 +569,24 @@ function normalizeUnion(
|
||||
return secretInput;
|
||||
}
|
||||
|
||||
// An exact boolean branch is finite, except oneOf cannot absorb boolean literals
|
||||
// that also match that branch. Open, nullable, or constrained branches stay in Raw mode.
|
||||
if (literals.length > 0 && remaining.length > 0) {
|
||||
const booleanBranch = remaining.length === 1 ? remaining[0] : undefined;
|
||||
const plainBooleanBranch =
|
||||
booleanBranch?.type === "boolean" && Object.keys(booleanBranch).length === 1;
|
||||
if (
|
||||
!plainBooleanBranch ||
|
||||
literals.includes("true") ||
|
||||
literals.includes("false") ||
|
||||
(schema.anyOf === undefined && literals.some((literal) => typeof literal === "boolean"))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
remaining.pop();
|
||||
literals.unshift(true, false);
|
||||
}
|
||||
|
||||
if (literals.length > 0 && remaining.length === 0) {
|
||||
return {
|
||||
schema: {
|
||||
@@ -584,12 +602,6 @@ function normalizeUnion(
|
||||
};
|
||||
}
|
||||
|
||||
// A native field cannot preserve both literal sentinels and an open typed branch.
|
||||
// Keep the original union for Raw mode instead of silently dropping valid values.
|
||||
if (literals.length > 0 && remaining.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (remaining.length === 1) {
|
||||
const remainingSchema = remaining[0];
|
||||
if (!remainingSchema) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
numericInputConstraints,
|
||||
} from "./config-form.constraints.ts";
|
||||
import {
|
||||
configEnumOptionLabel,
|
||||
getSensitiveRenderState,
|
||||
isSecretRefObject,
|
||||
jsonValue,
|
||||
@@ -527,7 +528,7 @@ export function renderSelect(
|
||||
${options.map(
|
||||
(option, index) => html`
|
||||
<option value=${String(index)} ?selected=${selectedValue === String(index)}>
|
||||
${String(option)}
|
||||
${configEnumOptionLabel(option, options)}
|
||||
</option>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -357,7 +357,7 @@ export function renderSegmentedControl(params: {
|
||||
value: selectedIndex < 0 ? "" : String(selectedIndex),
|
||||
options: params.options.map((option, index) => ({
|
||||
value: String(index),
|
||||
label: formatUnknownText(option),
|
||||
label: configEnumOptionLabel(option, params.options),
|
||||
})),
|
||||
disabled: params.disabled,
|
||||
ariaLabel: params.ariaLabel,
|
||||
@@ -370,6 +370,20 @@ export function renderSegmentedControl(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export function configEnumOptionLabel(option: unknown, options: readonly unknown[]): string {
|
||||
const presentsBooleanState = options.includes(true) && options.includes(false);
|
||||
if (!presentsBooleanState) {
|
||||
return formatUnknownText(option);
|
||||
}
|
||||
if (option === true) {
|
||||
return t("configForm.enumOn");
|
||||
}
|
||||
if (option === false) {
|
||||
return t("configForm.enumOff");
|
||||
}
|
||||
return option === "auto" ? t("configForm.enumAuto") : formatUnknownText(option);
|
||||
}
|
||||
|
||||
export function renderJsonTextareaControl(params: {
|
||||
schema: JsonSchema;
|
||||
path: Array<string | number>;
|
||||
|
||||
@@ -1180,6 +1180,9 @@ export const en: TranslationMap = {
|
||||
usingDefault: "Using default: {value}",
|
||||
resetToDefault: "Reset to default",
|
||||
select: "Select...",
|
||||
enumOn: "On",
|
||||
enumOff: "Off",
|
||||
enumAuto: "Auto",
|
||||
nullValue: "null",
|
||||
jsonValue: "JSON value",
|
||||
invalidJson: "Enter valid JSON before leaving this field.",
|
||||
|
||||
Reference in New Issue
Block a user