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:
parent
ca2846de28
commit
5933162dd0
1 changed files with 1259 additions and 29 deletions
|
|
@ -2,6 +2,7 @@ import {
|
|||
admitsNull,
|
||||
normalizeNullableUnion,
|
||||
} from "@inspector/core/json/nullableUnion.js";
|
||||
import { resolveRootUnion } from "@inspector/core/json/rootUnion.js";
|
||||
|
||||
export type JsonValue =
|
||||
| string
|
||||
|
|
@ -58,6 +59,11 @@ export type InspectorFormSchema = {
|
|||
const?: JsonValue;
|
||||
oneOf?: (InspectorFormSchema | JsonSchemaConst)[];
|
||||
anyOf?: (InspectorFormSchema | JsonSchemaConst)[];
|
||||
// Root composition the form flattens before rendering (#2123). `allOf` is
|
||||
// merged into the schema; `oneOf`/`anyOf` at the root become the branches the
|
||||
// Variant picker chooses between, labelled via `discriminator` when present.
|
||||
allOf?: InspectorFormSchema[];
|
||||
discriminator?: { propertyName?: string };
|
||||
$ref?: string;
|
||||
};
|
||||
|
||||
|
|
@ -119,7 +125,13 @@ export function getDataType(value: JsonValue): DataType {
|
|||
export function collectSchemaDefaults(
|
||||
schema: InspectorFormSchema,
|
||||
): Record<string, unknown> {
|
||||
const properties = schema.properties ?? {};
|
||||
// Seed from the shape the form actually renders: root `allOf` merged in, and
|
||||
// for a root union the branch the picker starts on (#2123). Seeding every
|
||||
// branch would put fields of shapes the call is not making into the
|
||||
// arguments; seeding none would leave the branch's defaults — its
|
||||
// discriminator `const` among them — displayed but never submitted.
|
||||
const { base, branches } = resolveRootUnion(schema);
|
||||
const properties = (branches[0]?.schema ?? base).properties ?? {};
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [fieldName, rawSchema] of Object.entries(properties)) {
|
||||
// Collapse a nullable union first, for the same reason `SchemaForm` does:
|
||||
|
|
@ -129,6 +141,11 @@ export function collectSchemaDefaults(
|
|||
const fieldSchema = normalizeNullableUnion(rawSchema);
|
||||
if (fieldSchema.default !== undefined) {
|
||||
result[fieldName] = fieldSchema.default;
|
||||
} else if (fieldSchema.const !== undefined) {
|
||||
// `const` is a one-value enumeration, so the only submittable value is
|
||||
// already known — seeding it spares the user hand-typing a discriminator
|
||||
// the schema has fixed (#2123), and matches what a `default` would do.
|
||||
result[fieldName] = fieldSchema.const;
|
||||
} else if (fieldSchema.type === "object" && fieldSchema.properties) {
|
||||
const nested = collectSchemaDefaults(fieldSchema);
|
||||
if (Object.keys(nested).length > 0) {
|
||||
|
|
@ -160,6 +177,25 @@ export function collectSchemaDefaults(
|
|||
export function hasMissingRequiredFields(
|
||||
schema: InspectorFormSchema,
|
||||
values: Record<string, unknown>,
|
||||
): boolean {
|
||||
// Root composition first, so a schema keeping its `required` on branches is
|
||||
// gated at all (#2123). For a union the answer is selection-independent by
|
||||
// construction: valid arguments must satisfy *some* branch, so the submit is
|
||||
// blocked only when **every** branch is still missing something. That is
|
||||
// deliberately weaker than gating on the branch the picker is showing — this
|
||||
// function is handed `values`, never the selection — but it is sound in the
|
||||
// direction that matters: it never blocks arguments the schema accepts.
|
||||
const { base, branches } = resolveRootUnion(schema);
|
||||
if (branches.length > 0) {
|
||||
return branches.every((branch) => hasMissingIn(branch.schema, values));
|
||||
}
|
||||
return hasMissingIn(base, values);
|
||||
}
|
||||
|
||||
/** {@link hasMissingRequiredFields} against one already-flattened schema. */
|
||||
function hasMissingIn(
|
||||
schema: InspectorFormSchema,
|
||||
values: Record<string, unknown>,
|
||||
): boolean {
|
||||
const required = schema.required ?? [];
|
||||
const properties = schema.properties ?? {};
|
||||
|
|
|
|||
Loading…
Reference in a new issue