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
|
|
@ -75,6 +75,50 @@ describe("JSON Utils", () => {
|
|||
},
|
||||
};
|
||||
|
||||
it("coerces a value whose schema lives on a root union branch (#2123)", () => {
|
||||
const unionTool: Tool = {
|
||||
name: "union-tool",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { note: { type: "string" } },
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { count: { type: "number" } },
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { enabled: { type: "boolean" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
// Reading the root's `properties` alone finds no schema for either, so
|
||||
// both would have been sent as the strings the user typed.
|
||||
expect(
|
||||
convertToolParameters(unionTool, {
|
||||
note: "hi",
|
||||
count: "42",
|
||||
enabled: "true",
|
||||
}),
|
||||
).toEqual({ note: "hi", count: 42, enabled: true });
|
||||
});
|
||||
|
||||
it("coerces a value whose schema lives on a root allOf branch (#2123)", () => {
|
||||
const allOfTool: Tool = {
|
||||
name: "allof-tool",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
allOf: [
|
||||
{ type: "object", properties: { count: { type: "number" } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(convertToolParameters(allOfTool, { count: "7" })).toEqual({
|
||||
count: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("should convert string parameters", () => {
|
||||
const result = convertToolParameters(tool, {
|
||||
message: "hello",
|
||||
|
|
|
|||
235
clients/web/src/test/core/rootUnion.test.ts
Normal file
235
clients/web/src/test/core/rootUnion.test.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { resolveRootUnion } from "@inspector/core/json/rootUnion.js";
|
||||
|
||||
const EMAIL = {
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: { type: "string", const: "email" },
|
||||
address: { type: "string" },
|
||||
},
|
||||
required: ["kind", "address"],
|
||||
};
|
||||
|
||||
const SMS = {
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: { type: "string", const: "sms" },
|
||||
phone: { type: "string" },
|
||||
},
|
||||
required: ["kind", "phone"],
|
||||
};
|
||||
|
||||
describe("resolveRootUnion", () => {
|
||||
it("leaves an ordinary object schema alone", () => {
|
||||
const schema = {
|
||||
type: "object" as const,
|
||||
properties: { message: { type: "string" as const } },
|
||||
required: ["message"],
|
||||
};
|
||||
const { base, branches } = resolveRootUnion(schema);
|
||||
expect(branches).toEqual([]);
|
||||
expect(base.properties).toEqual(schema.properties);
|
||||
expect(base.required).toEqual(["message"]);
|
||||
});
|
||||
|
||||
it("returns a branch per anyOf member, merged with the root", () => {
|
||||
const { base, branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
properties: { note: { type: "string" } },
|
||||
required: ["note"],
|
||||
anyOf: [EMAIL, SMS],
|
||||
});
|
||||
|
||||
// The root's own fields render for every branch, so they stay on the base
|
||||
// and are merged into each branch rather than belonging to one.
|
||||
expect(Object.keys(base.properties ?? {})).toEqual(["note"]);
|
||||
expect(branches).toHaveLength(2);
|
||||
expect(Object.keys(branches[0].schema.properties ?? {})).toEqual([
|
||||
"note",
|
||||
"kind",
|
||||
"address",
|
||||
]);
|
||||
expect(branches[0].schema.required).toEqual(["note", "kind", "address"]);
|
||||
expect(branches[0].ownFields).toEqual(["kind", "address"]);
|
||||
expect(branches[1].ownFields).toEqual(["kind", "phone"]);
|
||||
});
|
||||
|
||||
it("strips the composition keywords it has absorbed", () => {
|
||||
const { base, branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
anyOf: [EMAIL, SMS],
|
||||
});
|
||||
expect(base.anyOf).toBeUndefined();
|
||||
expect(branches[0].schema.anyOf).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers oneOf when a schema carries both", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
oneOf: [EMAIL],
|
||||
anyOf: [EMAIL, SMS],
|
||||
});
|
||||
expect(branches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("merges allOf branches unconditionally", () => {
|
||||
const { base, branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
properties: { a: { type: "string" } },
|
||||
required: ["a"],
|
||||
allOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { b: { type: "number" } },
|
||||
required: ["b"],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(branches).toEqual([]);
|
||||
expect(Object.keys(base.properties ?? {})).toEqual(["a", "b"]);
|
||||
expect(base.required).toEqual(["a", "b"]);
|
||||
expect(base.allOf).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges allOf into every union branch", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
allOf: [{ type: "object", properties: { shared: { type: "string" } } }],
|
||||
oneOf: [EMAIL, SMS],
|
||||
});
|
||||
expect(Object.keys(branches[0].schema.properties ?? {})).toContain(
|
||||
"shared",
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a branch's declaration win a name collision with the root", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
anyOf: [{ type: "object", properties: { id: { type: "number" } } }, SMS],
|
||||
});
|
||||
expect(branches[0].schema.properties?.id).toEqual({ type: "number" });
|
||||
// `required` unions rather than duplicating.
|
||||
expect(branches[0].schema.required).toEqual(["id"]);
|
||||
});
|
||||
|
||||
describe("branch labels", () => {
|
||||
it("uses the branch's own title first", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
anyOf: [{ ...EMAIL, title: "By email" }, SMS],
|
||||
});
|
||||
expect(branches[0].label).toBe("By email");
|
||||
});
|
||||
|
||||
it("uses the discriminator property's const when one is named", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
discriminator: { propertyName: "kind" },
|
||||
oneOf: [EMAIL, SMS],
|
||||
});
|
||||
expect(branches.map((branch) => branch.label)).toEqual(["email", "sms"]);
|
||||
});
|
||||
|
||||
it("uses a lone constant-valued property when there is no discriminator", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
anyOf: [EMAIL, SMS],
|
||||
});
|
||||
expect(branches.map((branch) => branch.label)).toEqual(["email", "sms"]);
|
||||
});
|
||||
|
||||
it("falls back to a position when a branch has several constants", () => {
|
||||
const twoConstants = {
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: { const: "a" },
|
||||
other: { const: "b" },
|
||||
},
|
||||
};
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
anyOf: [twoConstants, SMS],
|
||||
});
|
||||
expect(branches[0].label).toBe("Option 1");
|
||||
});
|
||||
|
||||
it("falls back to a position when a branch names no constant", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
anyOf: [
|
||||
{ type: "object", properties: { a: { type: "string" } } },
|
||||
{ type: "object", properties: { b: { type: "string" } } },
|
||||
],
|
||||
});
|
||||
expect(branches.map((branch) => branch.label)).toEqual([
|
||||
"Option 1",
|
||||
"Option 2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores a discriminator naming a property without a usable const", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
discriminator: { propertyName: "missing" },
|
||||
anyOf: [EMAIL, SMS],
|
||||
});
|
||||
expect(branches[0].label).toBe("email");
|
||||
});
|
||||
|
||||
it("labels a numeric const by its value", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
anyOf: [
|
||||
{ type: "object", properties: { v: { const: 1 } } },
|
||||
{ type: "object", properties: { v: { const: 2 } } },
|
||||
],
|
||||
});
|
||||
expect(branches.map((branch) => branch.label)).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("ignores a blank title", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
anyOf: [{ ...EMAIL, title: " " }, SMS],
|
||||
});
|
||||
expect(branches[0].label).toBe("email");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unions it declines to offer", () => {
|
||||
// Each of these would produce a picker with an option that renders
|
||||
// nothing, which is the failure this module exists to prevent.
|
||||
it("declines a union with a member that is not an object", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
anyOf: [EMAIL, "nope" as unknown],
|
||||
});
|
||||
expect(branches).toEqual([]);
|
||||
});
|
||||
|
||||
it("declines a union with a fieldless member", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
anyOf: [EMAIL, { type: "null" }],
|
||||
});
|
||||
expect(branches).toEqual([]);
|
||||
});
|
||||
|
||||
it("declines an empty union", () => {
|
||||
expect(resolveRootUnion({ type: "object", anyOf: [] }).branches).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores an allOf member that is not an object", () => {
|
||||
const { base } = resolveRootUnion({
|
||||
type: "object",
|
||||
properties: { a: { type: "string" } },
|
||||
allOf: [null as unknown, 3 as unknown],
|
||||
});
|
||||
expect(Object.keys(base.properties ?? {})).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue