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

View file

@ -340,3 +340,96 @@ describe("hasMissingRequiredFields", () => {
expect(hasMissingRequiredFields(undescribed, { ghost: null })).toBe(true);
});
});
describe("root composition (#2123)", () => {
const UNION: InspectorFormSchema = {
type: "object",
properties: { note: { type: "string" } },
anyOf: [
{
type: "object",
properties: {
kind: { type: "string", const: "email" },
address: { type: "string" },
},
required: ["kind", "address"],
},
{
type: "object",
properties: {
kind: { type: "string", const: "sms" },
phone: { type: "string" },
},
required: ["kind", "phone"],
},
],
};
it("seeds the first branch's defaults, including its const", () => {
expect(collectSchemaDefaults(UNION)).toEqual({ kind: "email" });
});
it("does not seed fields of branches the form is not showing", () => {
expect(collectSchemaDefaults(UNION)).not.toHaveProperty("phone");
});
it("seeds a const property on an ordinary schema too", () => {
expect(
collectSchemaDefaults({
type: "object",
properties: { version: { type: "string", const: "1" } },
}),
).toEqual({ version: "1" });
});
it("prefers an explicit default over a const", () => {
expect(
collectSchemaDefaults({
type: "object",
properties: {
v: { type: "string", const: "a", default: "b" },
},
}),
).toEqual({ v: "b" });
});
it("collects defaults from a root allOf", () => {
expect(
collectSchemaDefaults({
type: "object",
allOf: [
{
type: "object",
properties: { merged: { type: "string", default: "m" } },
},
],
}),
).toEqual({ merged: "m" });
});
it("blocks submission while no branch is satisfied", () => {
expect(hasMissingRequiredFields(UNION, {})).toBe(true);
expect(hasMissingRequiredFields(UNION, { kind: "email" })).toBe(true);
});
it("allows submission once one branch is satisfied", () => {
expect(hasMissingRequiredFields(UNION, { kind: "sms", phone: "555" })).toBe(
false,
);
});
it("gates on required fields declared only in a root allOf", () => {
const schema: InspectorFormSchema = {
type: "object",
allOf: [
{
type: "object",
properties: { a: { type: "string" } },
required: ["a"],
},
],
};
expect(hasMissingRequiredFields(schema, {})).toBe(true);
expect(hasMissingRequiredFields(schema, { a: "x" })).toBe(false);
});
});