fix: render tool schemas with root-level anyOf/oneOf composition

A tool's `inputSchema` may carry composition keywords at its root — the
2026-07-28 revision makes this explicit — but both form builders enumerate
the root's `properties` and nothing else. A schema keeping its fields on
`allOf`/`oneOf`/`anyOf` branches therefore rendered no controls at all: no
branch picker, no fields, not even the raw-JSON fallback a union-typed
*property* falls back to, so the tool could only be called with empty
arguments.

Adds `core/json/rootUnion.ts`, one flattening shared by all three clients so
they cannot drift on which schemas they can render:

- `allOf` is merged unconditionally (conjunctive — there is no choice to
  present), a root `oneOf`/`anyOf` is returned as branches.
- Web `SchemaForm` renders a Variant picker above the fields and prunes the
  outgoing branch's values on a switch, since they are no longer visible and
  would describe a shape the call is not making.
- TUI `schemaToForm` gives each branch its own section with optional fields —
  ink-form is static, and only one alternative applies to a call.
- `convertToolParameters` looks through the branches, so a CLI `--tool-arg`
  is typed by the schema that declares it rather than sent as a string.

Required-field gating asks whether *any* branch is satisfied, which is
selection-independent and never blocks arguments the schema accepts. A
`const` is seeded like a `default` and rendered read-only: it admits exactly
one value, so anything else the user could type produces a rejected call.

Adds a `root-union-schemas-http.json` showcase server.

Closes #2123

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 00:37:43 -04:00
commit 5933162dd0
3 changed files with 1259 additions and 29 deletions

View file

@ -180,3 +180,41 @@ export const Disabled: Story = {
disabled: true,
},
};
/**
* Arguments declared as a composition at the root of the schema (#2123): a
* picker chooses the alternative, and its fields render beneath. Before this,
* such a schema produced a form with no controls at all.
*/
export const RootUnion: Story = {
args: {
schema: {
type: "object",
properties: {
message: { type: "string", title: "Message" },
},
required: ["message"],
anyOf: [
{
type: "object",
title: "By email",
properties: {
kind: { type: "string", const: "email" },
address: { type: "string", title: "Address" },
},
required: ["kind", "address"],
},
{
type: "object",
title: "By SMS",
properties: {
kind: { type: "string", const: "sms" },
phone: { type: "string", title: "Phone" },
},
required: ["kind", "phone"],
},
],
},
values: { kind: "email" },
},
};

View file

@ -1969,4 +1969,166 @@ describe("SchemaForm multiline strings (#2042)", () => {
);
expect(screen.getByRole("button", { name: "Enlarge Note" })).toBeDisabled();
});
describe("a root-level union (#2123)", () => {
const UNION_SCHEMA: InspectorFormSchema = {
type: "object",
properties: { note: { type: "string", title: "Note" } },
anyOf: [
{
type: "object",
properties: {
kind: { type: "string", const: "email" },
address: { type: "string", title: "Address" },
},
required: ["kind", "address"],
},
{
type: "object",
properties: {
kind: { type: "string", const: "sms" },
phone: { type: "string", title: "Phone" },
},
required: ["kind", "phone"],
},
],
};
it("renders the first branch's fields with a picker, not an empty form", () => {
renderWithMantine(
<SchemaForm schema={UNION_SCHEMA} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByRole("textbox", { name: /Note/ })).toBeTruthy();
expect(screen.getByRole("textbox", { name: /Address/ })).toBeTruthy();
expect(screen.queryByRole("textbox", { name: /Phone/ })).toBeNull();
expect(
(screen.getByRole("textbox", { name: /Variant/ }) as HTMLInputElement)
.value,
).toBe("email");
});
it("switches to the chosen branch's fields", async () => {
const user = userEvent.setup();
renderWithMantine(
<SchemaForm schema={UNION_SCHEMA} values={{}} onChange={vi.fn()} />,
);
await user.click(screen.getByRole("textbox", { name: /Variant/ }));
await user.click(screen.getByRole("option", { name: "sms" }));
expect(screen.getByRole("textbox", { name: /Phone/ })).toBeTruthy();
expect(screen.queryByRole("textbox", { name: /Address/ })).toBeNull();
});
it("drops the outgoing branch's values and seeds the incoming branch's const", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithMantine(
<SchemaForm
schema={UNION_SCHEMA}
values={{ note: "hi", kind: "email", address: "a@b.c" }}
onChange={onChange}
/>,
);
await user.click(screen.getByRole("textbox", { name: /Variant/ }));
await user.click(screen.getByRole("option", { name: "sms" }));
// `address` belongs to a shape this call is no longer making, so it must
// not ride along invisibly into the submitted arguments.
expect(onChange).toHaveBeenCalledWith({ note: "hi", kind: "sms" });
});
it("renders a const-pinned field read-only", () => {
renderWithMantine(
<SchemaForm
schema={UNION_SCHEMA}
values={{ kind: "email" }}
onChange={vi.fn()}
/>,
);
const kind = screen.getByRole("textbox", {
name: /kind/,
}) as HTMLInputElement;
expect(kind.readOnly).toBe(true);
expect(kind.value).toBe("email");
});
it("renders no picker for a single-branch union but still shows its fields", () => {
const schema: InspectorFormSchema = {
type: "object",
anyOf: [
{
type: "object",
properties: { only: { type: "string", title: "Only" } },
},
],
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByRole("textbox", { name: /Only/ })).toBeTruthy();
expect(screen.queryByRole("textbox", { name: /Variant/ })).toBeNull();
});
it("renders root allOf fields", () => {
const schema: InspectorFormSchema = {
type: "object",
allOf: [
{
type: "object",
properties: { merged: { type: "string", title: "Merged" } },
},
],
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByRole("textbox", { name: /Merged/ })).toBeTruthy();
});
it("returns to the first branch when resetKey says the form moved on", async () => {
const user = userEvent.setup();
const { rerender } = renderWithMantine(
<SchemaForm
schema={UNION_SCHEMA}
values={{}}
onChange={vi.fn()}
resetKey="tool-a"
/>,
);
await user.click(screen.getByRole("textbox", { name: /Variant/ }));
await user.click(screen.getByRole("option", { name: "sms" }));
expect(screen.getByRole("textbox", { name: /Phone/ })).toBeTruthy();
rerender(
<SchemaForm
schema={UNION_SCHEMA}
values={{}}
onChange={vi.fn()}
resetKey="tool-b"
/>,
);
expect(screen.getByRole("textbox", { name: /Address/ })).toBeTruthy();
expect(screen.queryByRole("textbox", { name: /Phone/ })).toBeNull();
});
it("clamps a selection the next schema's shorter union cannot hold", async () => {
const user = userEvent.setup();
const { rerender } = renderWithMantine(
<SchemaForm schema={UNION_SCHEMA} values={{}} onChange={vi.fn()} />,
);
await user.click(screen.getByRole("textbox", { name: /Variant/ }));
await user.click(screen.getByRole("option", { name: "sms" }));
// No `resetKey`, so nothing resets the selection: index 1 must be clamped
// rather than reaching past the end of a one-branch union.
const shorter: InspectorFormSchema = {
type: "object",
anyOf: [
{
type: "object",
properties: { solo: { type: "string", title: "Solo" } },
},
],
};
rerender(<SchemaForm schema={shorter} values={{}} onChange={vi.fn()} />);
expect(screen.getByRole("textbox", { name: /Solo/ })).toBeTruthy();
});
});
});

View file

@ -31,6 +31,8 @@ import {
isStringEnum,
normalizeNullableUnion,
} from "@inspector/core/json/nullableUnion.js";
import { resolveRootUnion } from "@inspector/core/json/rootUnion.js";
import { collectSchemaDefaults } from "../../../utils/jsonUtils";
const FieldLabel = Text.withProps({
fw: 500,
@ -45,6 +47,14 @@ const FieldDescription = Text.withProps({
// Indented column for a nested object's sub-fields.
const IndentedStack = Stack.withProps({ gap: "sm", pl: "md" });
// The picker for a root `oneOf`/`anyOf` (#2123). Not clearable: one branch is
// always in effect, so "no branch" is not a state the arguments can be in.
const BranchSelect = Select.withProps({
label: "Variant",
description: "This tool accepts one of several argument shapes.",
allowDeselect: false,
});
const SchemaJsonInput = JsonInput.withProps({
formatOnBlur: true,
autosize: true,
@ -569,8 +579,27 @@ export function SchemaForm({
resetKey,
onValidityChange,
}: SchemaFormProps) {
const properties = schema.properties ?? {};
const requiredFields = schema.required ?? [];
// Composition at the root of the schema, flattened before anything is
// rendered (#2123). `allOf` is folded into `base`; a top-level `oneOf`/`anyOf`
// becomes the branches a picker chooses between. Both are empty for the
// ordinary object schema, where `base` is the schema itself.
const { base, branches } = resolveRootUnion(schema);
// Which alternative the form is currently showing. Held here because it is a
// property of this rendering, not of the arguments: `values` carries what the
// user typed, and nothing in it names a branch.
const [branchIndex, setBranchIndex] = useState(0);
// A form reused for another entity can be handed a shorter union, so the
// index is clamped rather than trusted — `resetKey` resets it below, but a
// caller that omits it (the elicitation panels mount fresh) supplies none.
const activeBranch =
branches.length > 0
? (branches[Math.min(branchIndex, branches.length - 1)] ?? null)
: null;
const effectiveSchema = activeBranch?.schema ?? base;
const properties = effectiveSchema.properties ?? {};
const requiredFields = effectiveSchema.required ?? [];
// The names of fields currently holding unsendable text. Held here rather
// than in each field because only the form sees them all, and only the form
@ -590,7 +619,12 @@ export function SchemaForm({
// *name*, so it must not carry across to another entity's same-named field —
// the same reasoning `resetKey` documents for the number field's draft. Reset
// during render rather than in an effect so no frame paints the wrong shape.
useValueChange(resetKey, () => setEnlargedFields(new Set()));
useValueChange(resetKey, () => {
setEnlargedFields(new Set());
// Which branch is selected belongs to the entity it was chosen for, for the
// same reason enlargement does.
setBranchIndex(0);
});
// Stable so a field's reporting effect subscribes once, not per render. The
// updater returns the previous set unchanged when nothing moved, which is
@ -637,6 +671,35 @@ export function SchemaForm({
onChange({ ...values, [fieldName]: fieldValue });
}
/**
* Switch branches, and move `values` with the form.
*
* The fields the outgoing branch owned are dropped rather than left behind:
* they are no longer rendered, so the user cannot see or clear them, and
* submitting them would send the server arguments belonging to a shape the
* call is not making. Whatever the base contributes is kept — it applies to
* every branch — and the incoming branch's defaults (a discriminator `const`
* among them) are seeded the way the initial ones were.
*/
function handleBranchChange(nextIndex: number) {
const nextBranch = branches[nextIndex];
/* v8 ignore next -- the Select's options are built from `branches` */
if (!nextBranch) return;
setBranchIndex(nextIndex);
const nextProperties = nextBranch.schema.properties ?? {};
const carried: Record<string, unknown> = {};
for (const [name, fieldSchema] of Object.entries(nextProperties)) {
// A field the incoming branch pins to a `const` is not carried: the two
// branches of a discriminated union share the discriminator's *name* and
// disagree about its value, so keeping what the outgoing branch put
// there would leave the arguments claiming the shape they no longer have.
if (values[name] !== undefined && fieldSchema.const === undefined) {
carried[name] = values[name];
}
}
onChange({ ...collectSchemaDefaults(nextBranch.schema), ...carried });
}
function renderField(fieldName: string, rawSchema: InspectorFormSchema) {
// Flatten a nullable union (`anyOf: [X, {type:"null"}]`, `type: [X,"null"]`)
// before dispatching. Every branch below tests a single `type` string, so
@ -687,6 +750,26 @@ 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 ? (
@ -878,6 +961,20 @@ export function SchemaForm({
return (
<Stack gap="sm">
{activeBranch && branches.length > 1 && (
<BranchSelect
data={branches.map((branch, index) => ({
value: String(index),
label: branch.label,
}))}
value={String(branches.indexOf(activeBranch))}
disabled={disabled}
onChange={(value) =>
/* v8 ignore next -- Select only ever reports one of its own options */
value === null ? undefined : handleBranchChange(Number(value))
}
/>
)}
{Object.entries(properties).map(([fieldName, fieldSchema]) =>
renderField(fieldName, fieldSchema),
)}