fix: address Copilot review — const precedence, TUI variant model, decline both-keyword unions

- rootUnion: decline a schema carrying BOTH oneOf and anyOf rather than
  reading one and silently dropping the other's constraints.
- SchemaForm: handle `const` before every type dispatch and for every
  constant type, so a numeric/boolean constant, or a schema carrying both
  `const` and `enum`, can no longer offer a forbidden value.
- collectSchemaDefaults / TUI initialValue: `const` outranks `default`,
  which is an annotation a schema may set to something its const rejects.
- convertToolParameters: resolve from the first branch's merged schema, so a
  branch's specialization of a root-declared property is what types the arg.
- TUI: ink-form keys values by field name across the whole form, so two
  branches' `kind` were one field and the later section's initial value
  decided what the earlier submitted. Branch fields now render under prefixed
  names behind a Variant select, and decodeFormValues translates back on
  submit, dropping every branch but the chosen one.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 01:01:54 -04:00
commit 2782ed75d1
2 changed files with 370 additions and 68 deletions

View file

@ -2049,6 +2049,57 @@ describe("SchemaForm multiline strings (#2042)", () => {
expect(kind.value).toBe("email");
});
it("renders a non-string constant read-only too", () => {
// Reached before the number/boolean dispatch, so neither offers a value
// the `const` forbids.
renderWithMantine(
<SchemaForm
schema={{
type: "object",
properties: {
n: { type: "number", const: 7, title: "N" },
b: { type: "boolean", const: true, title: "B" },
},
}}
values={{}}
onChange={vi.fn()}
/>,
);
expect(
(screen.getByRole("textbox", { name: /N/ }) as HTMLInputElement).value,
).toBe("7");
expect(
(screen.getByRole("textbox", { name: /B/ }) as HTMLInputElement).value,
).toBe("true");
});
it("keeps a const out of an enum select", () => {
// A schema carrying both would otherwise reach the select and offer the
// enum's other members, each of which the `const` rejects.
renderWithMantine(
<SchemaForm
schema={{
type: "object",
properties: {
mode: {
type: "string",
const: "fast",
enum: ["fast", "slow"],
title: "Mode",
},
},
}}
values={{}}
onChange={vi.fn()}
/>,
);
const mode = screen.getByRole("textbox", {
name: /Mode/,
}) as HTMLInputElement;
expect(mode.readOnly).toBe(true);
expect(mode.value).toBe("fast");
});
it("renders no picker for a single-branch union but still shows its fields", () => {
const schema: InspectorFormSchema = {
type: "object",

View file

@ -712,6 +712,35 @@ export function SchemaForm({
const description = fieldSchema.description;
const rawValue = resolveValue(values[fieldName], fieldSchema);
// A field pinned to a single value, checked **before** any type dispatch.
// `const` admits exactly one value, so every widget below it — the enum
// select, the number box, the checkbox — would offer values the schema
// forbids, and a schema carrying both `const` and `enum` would otherwise
// reach the select and submit a sibling of the one legal answer. Rendered
// read-only rather than editable for the same reason.
//
// Display only: the value that is *submitted* comes from `values`, seeded
// from the same `const` by `collectSchemaDefaults`, so a non-string
// constant keeps its type on the wire however it is shown here.
if (fieldSchema.const !== undefined) {
const constValue = fieldSchema.const;
return (
<TextInput
key={fieldName}
label={label}
description={description}
withAsterisk={isRequired}
readOnly
disabled={disabled}
value={
typeof constValue === "string"
? constValue
: serializeJson(constValue)
}
/>
);
}
// string with enum
if (fieldSchema.type === "string" && fieldSchema.enum) {
return (
@ -750,26 +779,6 @@ export function SchemaForm({
);
}
// A string pinned to a single value. Rendered read-only rather than as an
// editable box: `const` admits exactly one value, so anything the user
// could type into it produces a call the schema rejects. This is what a
// discriminated union's `kind`/`by` field is (#2123), and the picker has
// already set it — but the rule is the keyword's, not the union's, so it
// holds for a lone `const` property too.
if (typeof fieldSchema.const === "string") {
return (
<TextInput
key={fieldName}
label={label}
description={description}
withAsterisk={isRequired}
readOnly
disabled={disabled}
value={fieldSchema.const}
/>
);
}
// plain string
if (fieldSchema.type === "string") {
const clearButton = rawValue ? (