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:
Peter Steinberger
2026-08-10 21:28:06 -07:00
committed by GitHub
parent 19ed7feee1
commit ee7fae8a90
7 changed files with 151 additions and 12 deletions
+17
View File
@@ -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", () => {
+18 -6
View File
@@ -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) {
+2 -1
View File
@@ -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>
`,
)}
+15 -1
View File
@@ -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>;
+3
View File
@@ -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.",