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
2 changed files with 1259 additions and 29 deletions
|
|
@ -408,4 +408,89 @@ describe("schemaToForm", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
describe("root composition (#2123)", () => {
|
||||
const UNION = {
|
||||
type: "object",
|
||||
properties: { note: { type: "string" } },
|
||||
discriminator: { propertyName: "kind" },
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: { type: "string", const: "email" },
|
||||
address: { type: "string" },
|
||||
},
|
||||
required: ["kind", "address"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: { type: "string", const: "sms" },
|
||||
count: { type: "integer" },
|
||||
},
|
||||
required: ["kind", "count"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("gives each branch its own section instead of rendering no fields", () => {
|
||||
const form = schemaToForm(UNION, "union_tool");
|
||||
expect(form.sections.map((section) => section.title)).toEqual([
|
||||
"Parameters",
|
||||
"email",
|
||||
"sms",
|
||||
]);
|
||||
expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([
|
||||
"note",
|
||||
]);
|
||||
expect(form.sections[1]!.fields.map((field) => field.name)).toEqual([
|
||||
"kind",
|
||||
"address",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a branch's typed fields typed", () => {
|
||||
const form = schemaToForm(UNION, "union_tool");
|
||||
expect(form.sections[2]!.fields[1]).toMatchObject({
|
||||
name: "count",
|
||||
type: "integer",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders branch fields optional, since only one branch applies", () => {
|
||||
const form = schemaToForm(UNION, "union_tool");
|
||||
for (const field of form.sections[1]!.fields) {
|
||||
expect(field.required).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("seeds a branch's discriminator const so it need not be typed", () => {
|
||||
const form = schemaToForm(UNION, "union_tool");
|
||||
expect(form.sections[1]!.fields[0]).toMatchObject({
|
||||
name: "kind",
|
||||
initialValue: "email",
|
||||
});
|
||||
});
|
||||
|
||||
it("merges a root allOf into the parameters section", () => {
|
||||
const form = schemaToForm(
|
||||
{
|
||||
type: "object",
|
||||
properties: { a: { type: "string" } },
|
||||
allOf: [{ type: "object", properties: { b: { type: "boolean" } } }],
|
||||
},
|
||||
"allof_tool",
|
||||
);
|
||||
expect(form.sections).toHaveLength(1);
|
||||
expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("still renders an empty form for a schema with no properties", () => {
|
||||
const form = schemaToForm({ type: "object" }, "empty_tool");
|
||||
expect(form.sections).toEqual([{ title: "Parameters", fields: [] }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
isStringEnum,
|
||||
normalizeNullableUnion,
|
||||
} from "@inspector/core/json/nullableUnion.js";
|
||||
import { resolveRootUnion } from "@inspector/core/json/rootUnion.js";
|
||||
|
||||
/** Minimal JSON Schema property shape used when building tool parameter forms */
|
||||
interface JsonSchemaProperty {
|
||||
|
|
@ -24,6 +25,8 @@ interface JsonSchemaProperty {
|
|||
minimum?: number;
|
||||
maximum?: number;
|
||||
default?: unknown;
|
||||
/** A one-value enumeration; seeded like a `default` — see below. */
|
||||
const?: unknown;
|
||||
/** Present on a nullable union; see {@link normalizeNullableUnion}. */
|
||||
anyOf?: readonly unknown[];
|
||||
}
|
||||
|
|
@ -54,6 +57,17 @@ function toSelectOptions(
|
|||
interface JsonSchemaObject {
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
/**
|
||||
* Root composition, read by {@link resolveRootUnion} before `properties` is
|
||||
* enumerated (#2123). Members are `unknown` for the same reason property
|
||||
* values are: the SDK's `Tool["inputSchema"]` types them as the recursive
|
||||
* JSON type, and each is narrowed where it is used.
|
||||
*/
|
||||
type?: string | string[];
|
||||
allOf?: readonly unknown[];
|
||||
anyOf?: readonly unknown[];
|
||||
oneOf?: readonly unknown[];
|
||||
discriminator?: { propertyName?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -63,15 +77,44 @@ export function schemaToForm(
|
|||
schema: JsonSchemaObject | null | undefined,
|
||||
toolName: string,
|
||||
): FormStructure {
|
||||
const fields: FormField[] = [];
|
||||
|
||||
if (!schema || !schema.properties) {
|
||||
return {
|
||||
title: `Test Tool: ${toolName}`,
|
||||
sections: [{ title: "Parameters", fields: [] }],
|
||||
};
|
||||
const title = `Test Tool: ${toolName}`;
|
||||
if (!schema) {
|
||||
return { title, sections: [{ title: "Parameters", fields: [] }] };
|
||||
}
|
||||
|
||||
// Flatten root composition before reading `properties` (#2123). Without it a
|
||||
// tool whose arguments are declared as a root `oneOf`/`anyOf` — legal since
|
||||
// the 2026-07-28 revision — rendered a form with no fields at all, so it
|
||||
// could only be called with empty arguments.
|
||||
const { base, branches } = resolveRootUnion(schema);
|
||||
|
||||
const sections: FormSection[] = [
|
||||
{ title: "Parameters", fields: buildFields(base) },
|
||||
];
|
||||
|
||||
// ink-form is static — there is no branch picker to hide the alternatives
|
||||
// behind — so every branch gets its own section and the user fills the one
|
||||
// they mean. A branch's fields are rendered **optional** whatever the branch
|
||||
// says: only one alternative applies to a given call, so requiring them would
|
||||
// make a form that can never be submitted. An untouched field reports no
|
||||
// value and is dropped before the call, so the sections the user skipped
|
||||
// contribute nothing to the arguments.
|
||||
for (const branch of branches) {
|
||||
const ownProperties = Object.fromEntries(
|
||||
branch.ownFields.map((name) => [name, branch.schema.properties?.[name]]),
|
||||
);
|
||||
sections.push({
|
||||
title: branch.label,
|
||||
fields: buildFields({ properties: ownProperties }),
|
||||
});
|
||||
}
|
||||
|
||||
return { title, sections };
|
||||
}
|
||||
|
||||
/** Build the ink-form fields for one already-flattened object schema. */
|
||||
function buildFields(schema: JsonSchemaObject): FormField[] {
|
||||
const fields: FormField[] = [];
|
||||
const properties = schema.properties || {};
|
||||
const required = schema.required || [];
|
||||
|
||||
|
|
@ -160,24 +203,18 @@ export function schemaToForm(
|
|||
}
|
||||
}
|
||||
|
||||
// Set initial value from default (ink-form FormField allows initialValue for some types)
|
||||
if (property.default !== undefined) {
|
||||
// Set initial value from default (ink-form FormField allows initialValue for some types).
|
||||
// A `const` is seeded the same way: it is a one-value enumeration, so the
|
||||
// only submittable value is already known and the user would otherwise have
|
||||
// to hand-type a union's discriminator (#2123).
|
||||
const initialValue = property.default ?? property.const;
|
||||
if (initialValue !== undefined) {
|
||||
(field as FormField & { initialValue?: unknown }).initialValue =
|
||||
property.default;
|
||||
initialValue;
|
||||
}
|
||||
|
||||
fields.push(field);
|
||||
}
|
||||
|
||||
const sections: FormSection[] = [
|
||||
{
|
||||
title: "Parameters",
|
||||
fields,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
title: `Test Tool: ${toolName}`,
|
||||
sections,
|
||||
};
|
||||
return fields;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue