fix: address Copilot review round 2 — conjunctive merges, generated-name collisions, CLI branch selection

- rootUnion: a name both the root and a branch declare is now MERGED rather
  than replaced, so a root `minimum` survives a branch `maximum`; a branch
  that contradicts the root on a property's `type` describes an unsatisfiable
  value and is declined outright. Branch offerability also requires that
  `properties` really is an object (a malformed `properties: null` threw)
  and that the member's own `type` admits objects.
- rootUnion: `ownFields` becomes `declaredFields` — a branch commonly
  specializes a root property, and consumers must render the specialization.
- TUI: the generated select and branch prefixes step aside from any name the
  schema itself declares (JSON has no reserved namespace), decode filters them
  by exact name, a branch's specialization renders in its own section, and a
  `const` is rendered as a one-option select whose typed value is restored
  from the schema on submit.
- convertToolParameters: identify the branch from the discriminator constants
  the call supplies; when none identifies one, coerce only names every
  declaring branch types the same way rather than picking an arbitrary branch.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 01:41:17 -04:00
commit 84cd0c10f0
7 changed files with 635 additions and 107 deletions

View file

@ -304,9 +304,11 @@ The **TUI** has the same gap and is worth checking against the same server (`--t
The sections are not as independent as they look, which is why the select is not cosmetic: ink-form keeps one value object for the whole form, keyed by field name alone, so two branches both declaring `kind` would be **one** field and the later section's initial value would decide what the earlier one submits. Each branch's fields are therefore rendered under a prefixed name and translated back on submit, where every branch but the chosen one is dropped. The sections are not as independent as they look, which is why the select is not cosmetic: ink-form keeps one value object for the whole form, keyed by field name alone, so two branches both declaring `kind` would be **one** field and the later section's initial value would decide what the earlier one submits. Each branch's fields are therefore rendered under a prefixed name and translated back on submit, where every branch but the chosen one is dropped.
The **CLI** has no form at all, but the same flattening decides how `--tool-arg` values are typed: a branch's `count: { "type": "number" }` is what turns `--tool-arg count=3` into `3` rather than `"3"`. All three read one helper, [`core/json/rootUnion.ts`](./core/json/rootUnion.ts), so they cannot drift on which schemas they can render. The **CLI** has no form at all, but the same flattening decides how `--tool-arg` values are typed: a branch's `count: { "type": "number" }` is what turns `--tool-arg count=3` into `3` rather than `"3"`. Which branch is *inferred* rather than chosen — a discriminated union pins its discriminator with `const`, so the supplied arguments either identify one branch or they do not. When nothing identifies one, only the names every branch that declares them types the same way are coerced; a name one branch calls a number and another a boolean is passed through as the string it was typed as, rather than run through an arbitrary branch's schema.
Three things it deliberately does **not** do, each falling back to whatever the root `properties` describe rather than claiming something untrue. A union whose members are not all field-carrying object schemas is left alone rather than offered as a picker with options that render nothing. A schema carrying **both** `oneOf` and `anyOf` is declined outright: they are independent keywords a value satisfies together, not two spellings of one union, so reading one and dropping the other builds a form that silently omits real constraints — and satisfying both honestly means offering the cross product of their alternatives, which no real schema has yet asked for. And `not` is not interpreted at all: there is no faithful form for "anything except this". All three read one helper, [`core/json/rootUnion.ts`](./core/json/rootUnion.ts), so they cannot drift on which schemas they can render.
Four things it deliberately does **not** do, each falling back to whatever the root `properties` describe rather than claiming something untrue. A union whose members are not all field-carrying object schemas is left alone rather than offered as a picker with options that render nothing — as is one whose member `type` rules objects out, since tool arguments are a JSON object and such a member can never match. A union whose branch **contradicts** the root about a property's `type` is declined too: the two constraints are conjunctive, so `string` under a root `number` describes a value that cannot exist, and rendering either type would accept what the schema rejects. (A property both declare *compatibly* is merged rather than replaced, so a root's `minimum` survives a branch's `maximum`.) A schema carrying **both** `oneOf` and `anyOf` is declined outright: they are independent keywords a value satisfies together, not two spellings of one union, so reading one and dropping the other builds a form that silently omits real constraints — and satisfying both honestly means offering the cross product of their alternatives, which no real schema has yet asked for. And `not` is not interpreted at all: there is no faithful form for "anything except this".
#### Unportable tool schemas #### Unportable tool schemas

View file

@ -1,9 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { import { decodeFormValues, schemaToForm } from "../src/utils/schemaToForm.js";
decodeFormValues,
schemaToForm,
VARIANT_FIELD,
} from "../src/utils/schemaToForm.js";
describe("schemaToForm", () => { describe("schemaToForm", () => {
it("returns an empty Parameters section when there is no schema", () => { it("returns an empty Parameters section when there is no schema", () => {
@ -445,7 +441,7 @@ describe("schemaToForm", () => {
"sms", "sms",
]); ]);
expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([ expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([
VARIANT_FIELD, "__variant",
"note", "note",
]); ]);
}); });
@ -468,7 +464,7 @@ describe("schemaToForm", () => {
it("offers a variant select listing the alternatives", () => { it("offers a variant select listing the alternatives", () => {
const form = schemaToForm(UNION, "union_tool"); const form = schemaToForm(UNION, "union_tool");
expect(form.sections[0]!.fields[0]).toMatchObject({ expect(form.sections[0]!.fields[0]).toMatchObject({
name: VARIANT_FIELD, name: "__variant",
type: "select", type: "select",
initialValue: "0", initialValue: "0",
options: [ options: [
@ -493,15 +489,17 @@ describe("schemaToForm", () => {
} }
}); });
it("seeds a branch's discriminator const so it need not be typed", () => { it("renders a const as a one-option select so it cannot be changed", () => {
const form = schemaToForm(UNION, "union_tool"); const form = schemaToForm(UNION, "union_tool");
expect(form.sections[1]!.fields[0]).toMatchObject({ expect(form.sections[1]!.fields[0]).toMatchObject({
name: "__b0__kind", name: "__b0__kind",
type: "select",
initialValue: "email", initialValue: "email",
options: [{ label: "email", value: "email" }],
}); });
}); });
it("prefers a const over a conflicting default", () => { it("renders a const outside a union the same way", () => {
const form = schemaToForm( const form = schemaToForm(
{ {
type: "object", type: "object",
@ -509,14 +507,62 @@ describe("schemaToForm", () => {
}, },
"const_tool", "const_tool",
); );
expect(form.sections[0]!.fields[0]).toMatchObject({ initialValue: "a" }); expect(form.sections[0]!.fields[0]).toMatchObject({
type: "select",
initialValue: "a",
options: [{ label: "a", value: "a" }],
});
});
it("renders a branch's specialization of a root property in its section", () => {
const form = schemaToForm(
{
type: "object",
properties: { count: {} },
anyOf: [
{ type: "object", properties: { count: { type: "integer" } } },
{ type: "object", properties: { other: { type: "string" } } },
],
},
"specializing",
);
// The untyped base declaration is not rendered a second time as a string.
expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([
"__variant",
]);
expect(form.sections[1]!.fields[0]).toMatchObject({
name: "__b0__count",
type: "integer",
});
});
it("keeps its generated names clear of the schema's own", () => {
const form = schemaToForm(
{
type: "object",
properties: { __variant: { type: "string" } },
anyOf: [
{ type: "object", properties: { __b0__x: { type: "string" } } },
{ type: "object", properties: { y: { type: "string" } } },
],
},
"colliding",
);
const names = form.sections.flatMap((section) =>
section.fields.map((field) => field.name),
);
// The select steps aside for the declared `__variant`, and the branch
// prefix steps aside for the declared `__b0__x`.
expect(names).toContain("__variant_");
expect(names).toContain("__variant");
expect(names).toContain("__b_0____b0__x");
}); });
describe("decodeFormValues", () => { describe("decodeFormValues", () => {
it("submits the chosen branch's fields under their real names", () => { it("submits the chosen branch's fields under their real names", () => {
expect( expect(
decodeFormValues(UNION, { decodeFormValues(UNION, {
[VARIANT_FIELD]: "0", __variant: "0",
note: "hi", note: "hi",
__b0__kind: "email", __b0__kind: "email",
__b0__address: "a@b.c", __b0__address: "a@b.c",
@ -529,7 +575,7 @@ describe("schemaToForm", () => {
it("drops the branches the call is not making", () => { it("drops the branches the call is not making", () => {
expect( expect(
decodeFormValues(UNION, { decodeFormValues(UNION, {
[VARIANT_FIELD]: "1", __variant: "1",
__b0__kind: "email", __b0__kind: "email",
__b0__address: "a@b.c", __b0__address: "a@b.c",
__b1__kind: "sms", __b1__kind: "sms",
@ -541,7 +587,7 @@ describe("schemaToForm", () => {
it("omits a branch field the user never filled", () => { it("omits a branch field the user never filled", () => {
expect( expect(
decodeFormValues(UNION, { decodeFormValues(UNION, {
[VARIANT_FIELD]: "0", __variant: "0",
__b0__kind: "email", __b0__kind: "email",
}), }),
).toEqual({ kind: "email" }); ).toEqual({ kind: "email" });
@ -550,7 +596,7 @@ describe("schemaToForm", () => {
it("falls back to the first branch on an unusable selection", () => { it("falls back to the first branch on an unusable selection", () => {
expect( expect(
decodeFormValues(UNION, { decodeFormValues(UNION, {
[VARIANT_FIELD]: "nonsense", __variant: "nonsense",
__b0__kind: "email", __b0__kind: "email",
}), }),
).toEqual({ kind: "email" }); ).toEqual({ kind: "email" });
@ -562,6 +608,44 @@ describe("schemaToForm", () => {
values, values,
); );
}); });
it("restores a const from the schema rather than trusting the form", () => {
// ink-form has no immutable field, and a select hands back a string —
// so the pinned value is re-applied on the way out, with its own type.
expect(
decodeFormValues(
{
type: "object",
anyOf: [
{
type: "object",
properties: { n: { const: 7 }, a: { type: "string" } },
},
{ type: "object", properties: { n: { const: 8 } } },
],
},
{ __variant: "0", __b0__n: "tampered", __b0__a: "x" },
),
).toEqual({ n: 7, a: "x" });
});
it("keeps a base argument whose name looks generated", () => {
const schema = {
type: "object",
properties: { __b0__x: { type: "string" } },
anyOf: [
{ type: "object", properties: { a: { type: "string" } } },
{ type: "object", properties: { b: { type: "string" } } },
],
};
expect(
decodeFormValues(schema, {
__variant: "0",
__b0__x: "mine",
__b_0__a: "chosen",
}),
).toEqual({ __b0__x: "mine", a: "chosen" });
});
}); });
it("merges a root allOf into the parameters section", () => { it("merges a root allOf into the parameters section", () => {

View file

@ -80,11 +80,46 @@ interface JsonSchemaObject {
* submit the second branch's discriminator. Prefixing each branch's fields and * submit the second branch's discriminator. Prefixing each branch's fields and
* choosing between them explicitly is what makes the alternatives independent. * choosing between them explicitly is what makes the alternatives independent.
*/ */
export const VARIANT_FIELD = "__variant"; /**
* The generated field names a root-union form uses, chosen so they cannot
* collide with a property the schema itself declares.
*
* JSON object property names have no reserved namespace: a server may declare
* an argument called `__variant`, or one starting with `__b`. A fixed prefix
* would then either be shadowed by that argument or would silently swallow it
* on the way out, so both names are extended with `_` until nothing declared
* can be confused with them. Derived from the schema alone, so
* {@link schemaToForm} and {@link decodeFormValues} compute the same names
* without passing anything between them.
*/
function generatedNames(
base: { properties?: Record<string, unknown> },
branches: { declaredFields: string[] }[],
): { variant: string; prefix: string } {
const declared = [
...Object.keys(base.properties ?? {}),
...branches.flatMap((branch) => branch.declaredFields),
];
let variant = "__variant";
while (declared.includes(variant)) variant += "_";
let prefix = "__b";
while (declared.some((name) => name.startsWith(prefix))) prefix += "_";
return { variant, prefix };
}
/** The form-local name a branch's field is rendered under. */ /** The form-local name a branch's field is rendered under. */
function branchFieldName(branchIndex: number, name: string): string { function branchFieldName(
return `__b${branchIndex}__${name}`; prefix: string,
branchIndex: number,
name: string,
): string {
return `${prefix}${branchIndex}__${name}`;
}
/** The `const` a property schema pins its value to, if any. */
function constOf(schema: unknown): unknown {
if (typeof schema !== "object" || schema === null) return undefined;
return (schema as { const?: unknown }).const;
} }
/** /**
@ -105,14 +140,35 @@ export function schemaToForm(
// could only be called with empty arguments. // could only be called with empty arguments.
const { base, branches } = resolveRootUnion(schema); const { base, branches } = resolveRootUnion(schema);
const parameters = buildFields(base); if (branches.length === 0) {
if (branches.length > 0) { return {
// ink-form is static, so there is no picker that can swap the fields out. title,
// Every branch is rendered instead, and this select says which one the sections: [{ title: "Parameters", fields: buildFields(base) }],
// call means — read back by {@link decodeFormValues}, which drops the rest. };
parameters.unshift({ }
const { variant, prefix } = generatedNames(base, branches);
const branchDeclared = new Set(
branches.flatMap((branch) => branch.declaredFields),
);
// The base section renders what the base *alone* declares. A property a
// branch also declares is rendered in that branch's section instead, showing
// the branch's specialization — root `count: {}` under branch
// `count: { type: "number" }` is a number field there, not a string here.
const baseProperties = Object.fromEntries(
Object.entries(base.properties ?? {}).filter(
([name]) => !branchDeclared.has(name),
),
);
// ink-form is static, so there is no picker that can swap the fields out.
// Every branch is rendered instead, and this select says which one the call
// means — read back by `decodeFormValues`, which drops the rest.
const parameters: FormField[] = [
{
type: "select", type: "select",
name: VARIANT_FIELD, name: variant,
label: "Variant", label: "Variant",
required: true, required: true,
initialValue: "0", initialValue: "0",
@ -120,8 +176,9 @@ export function schemaToForm(
label: branch.label, label: branch.label,
value: String(index), value: String(index),
})), })),
} as FormField); } as FormField,
} ...buildFields({ properties: baseProperties, required: base.required }),
];
const sections: FormSection[] = [{ title: "Parameters", fields: parameters }]; const sections: FormSection[] = [{ title: "Parameters", fields: parameters }];
@ -129,15 +186,15 @@ export function schemaToForm(
// says: only one alternative applies to a call, so requiring them would build // says: only one alternative applies to a call, so requiring them would build
// a form that can never be submitted. // a form that can never be submitted.
branches.forEach((branch, index) => { branches.forEach((branch, index) => {
const ownProperties = Object.fromEntries( const properties = Object.fromEntries(
branch.ownFields.map((name) => [ branch.declaredFields.map((name) => [
branchFieldName(index, name), branchFieldName(prefix, index, name),
branch.schema.properties?.[name], branch.schema.properties?.[name],
]), ]),
); );
sections.push({ sections.push({
title: branch.label, title: branch.label,
fields: buildFields({ properties: ownProperties }), fields: buildFields({ properties }),
}); });
}); });
@ -146,45 +203,75 @@ export function schemaToForm(
/** /**
* Turn what the form submitted back into the arguments the server expects: * Turn what the form submitted back into the arguments the server expects:
* the base fields, plus the fields of the branch the {@link VARIANT_FIELD} * the base fields, plus the fields of the branch the variant select names,
* select names, under their real property names. * under their real property names.
* *
* Every other branch's fields are dropped rather than sent — they describe a * Every other branch's fields are dropped rather than sent — they describe a
* shape this call is not making, and the user filled at most one section. Call * shape this call is not making, and the user filled at most one section. The
* this on the way out of the form; for a schema with no root union it returns * generated names are filtered by exact match rather than by prefix, so an
* the values unchanged, so it is safe to apply unconditionally. * argument the server really named `__b0__x` survives.
*
* A `const` is re-applied from the schema rather than taken from the form.
* ink-form has no immutable field, so a discriminator is rendered as a
* one-option select and its value is restored here regardless — which also
* keeps a non-string constant's type, since a select hands back a string.
*
* Call this on the way out of the form; for a schema with no root union it
* returns the values unchanged, so it is safe to apply unconditionally.
*/ */
export function decodeFormValues<T>( export function decodeFormValues<T>(
schema: JsonSchemaObject | null | undefined, schema: JsonSchemaObject | null | undefined,
values: Record<string, T>, values: Record<string, T>,
): Record<string, T> { ): Record<string, T> {
const { branches } = resolveRootUnion(schema ?? {}); const { base, branches } = resolveRootUnion(schema ?? {});
if (branches.length === 0) { if (branches.length === 0) {
return values; return applyConstants(base.properties ?? {}, values);
} }
const raw = values[VARIANT_FIELD]; const { variant, prefix } = generatedNames(base, branches);
const selected = Number(raw); const selected = Number(values[variant]);
const branchIndex = const branchIndex =
Number.isInteger(selected) && selected >= 0 && selected < branches.length Number.isInteger(selected) && selected >= 0 && selected < branches.length
? selected ? selected
: 0; : 0;
const branch = branches[branchIndex]!;
const generated = new Set<string>([variant]);
branches.forEach((each, index) => {
for (const name of each.declaredFields) {
generated.add(branchFieldName(prefix, index, name));
}
});
const decoded: Record<string, T> = {}; const decoded: Record<string, T> = {};
for (const [name, value] of Object.entries(values)) { for (const [name, value] of Object.entries(values)) {
// Skip the select itself and every branch's prefixed field; the chosen if (!generated.has(name)) {
// branch's are re-added below under the names the schema declares.
if (name !== VARIANT_FIELD && !name.startsWith("__b")) {
decoded[name] = value; decoded[name] = value;
} }
} }
for (const name of branches[branchIndex]!.ownFields) { for (const name of branch.declaredFields) {
const value = values[branchFieldName(branchIndex, name)]; const value = values[branchFieldName(prefix, branchIndex, name)];
if (value !== undefined) { if (value !== undefined) {
decoded[name] = value; decoded[name] = value;
} }
} }
return decoded; return applyConstants(branch.schema.properties ?? {}, decoded);
}
/** Overwrite every `const`-pinned field with the value its schema fixes. */
function applyConstants<T>(
properties: Record<string, unknown>,
values: Record<string, T>,
): Record<string, T> {
const pinned = Object.entries(properties).filter(
([, schema]) => constOf(schema) !== undefined,
);
if (pinned.length === 0) return values;
const result = { ...values };
for (const [name, schema] of pinned) {
result[name] = constOf(schema) as T;
}
return result;
} }
/** Build the ink-form fields for one already-flattened object schema. */ /** Build the ink-form fields for one already-flattened object schema. */
@ -215,6 +302,21 @@ function buildFields(schema: JsonSchemaObject): FormField[] {
let field: FormField; let field: FormField;
// A `const` admits exactly one value, and ink-form has no read-only field —
// so it is rendered as a select with that single option, which the user
// cannot change (#2123). `decodeFormValues` restores the schema's own typed
// value on submit, since a select hands back a string.
const pinned = property.const;
if (pinned !== undefined) {
fields.push({
type: "select",
...baseField,
initialValue: String(pinned),
options: [{ label: String(pinned), value: String(pinned) }],
} as FormField);
continue;
}
// Handle enum -> select. Detect the array-of-enums case on `items.enum` // Handle enum -> select. Detect the array-of-enums case on `items.enum`
// alone (matching the web SchemaForm guard) — a standard array-of-enums // alone (matching the web SchemaForm guard) — a standard array-of-enums
// schema carries no top-level `enum`, so gating on it would drop the field // schema carries no top-level `enum`, so gating on it would drop the field
@ -279,17 +381,11 @@ function buildFields(schema: JsonSchemaObject): FormField[] {
} }
// Set initial value from default (ink-form FormField allows initialValue for some types). // Set initial value from default (ink-form FormField allows initialValue for some types).
// A `const` is seeded the same way and OUTRANKS `default`: it is a // A `const` never reaches here — it was rendered as its own one-option
// one-value enumeration, so the only submittable value is already known and // select above, which is also why `default` needs no precedence rule.
// the user would otherwise have to hand-type a union's discriminator if (property.default !== undefined) {
// (#2123), while `default` is an annotation a schema may set to something
// its own `const` rejects. Tested against `undefined` rather than `??`
// chained, so an explicit `null` default is honored as a value.
const initialValue =
property.const !== undefined ? property.const : property.default;
if (initialValue !== undefined) {
(field as FormField & { initialValue?: unknown }).initialValue = (field as FormField & { initialValue?: unknown }).initialValue =
initialValue; property.default;
} }
fields.push(field); fields.push(field);

View file

@ -123,6 +123,152 @@ describe("JSON Utils", () => {
}); });
}); });
it("picks the branch its discriminator names (#2123)", () => {
const discriminated: Tool = {
name: "discriminated",
inputSchema: {
type: "object",
oneOf: [
{
type: "object",
properties: {
kind: { type: "string", const: "a" },
value: { type: "number" },
},
},
{
type: "object",
properties: {
kind: { type: "string", const: "b" },
value: { type: "boolean" },
},
},
],
},
};
// `value` is a number in one branch and a boolean in the other, so the
// discriminator is the only thing that says how to coerce it.
expect(
convertToolParameters(discriminated, { kind: "b", value: "true" }),
).toEqual({ kind: "b", value: true });
expect(
convertToolParameters(discriminated, { kind: "a", value: "3" }),
).toEqual({ kind: "a", value: 3 });
});
it("leaves an ambiguously typed argument uncoerced (#2123)", () => {
const ambiguous: Tool = {
name: "ambiguous",
inputSchema: {
type: "object",
anyOf: [
{
type: "object",
properties: {
value: { type: "number" },
a: { type: "string" },
},
},
{
type: "object",
properties: {
value: { type: "boolean" },
b: { type: "string" },
},
},
],
},
};
// Nothing identifies a branch, so coercing `value` by an arbitrary one
// would turn `true` into `Number("true")` — `NaN`, i.e. `null` on the
// wire. The raw string is honest; it is also what shipped before.
expect(convertToolParameters(ambiguous, { value: "true" })).toEqual({
value: "true",
});
});
it("agrees on an array-form type across branches (#2123)", () => {
const arrayTyped: Tool = {
name: "array-typed",
inputSchema: {
type: "object",
anyOf: [
{
type: "object",
properties: { v: { type: ["number", "null"] }, a: {} },
},
{
type: "object",
properties: { v: { type: ["number", "null"] }, b: {} },
},
],
},
};
// Both spell the type the same way, so it is not ambiguous.
expect(convertToolParameters(arrayTyped, { v: "2" })).toEqual({ v: "2" });
});
it("ignores a malformed branch declaration when matching constants (#2123)", () => {
const malformed: Tool = {
name: "malformed",
inputSchema: {
type: "object",
anyOf: [
{
type: "object",
properties: {
kind: { type: "string", const: "a" },
broken: null as unknown,
value: { type: "number" },
},
},
{
type: "object",
properties: {
kind: { type: "string", const: "b" },
value: { type: "boolean" },
},
},
],
},
};
// A `properties: { broken: null }` entry must not throw, and a branch is
// still identifiable by the discriminator that *is* well-formed.
expect(
convertToolParameters(malformed, { kind: "a", value: "3" }),
).toEqual({ kind: "a", value: 3 });
});
it("falls back to the branch-agreement path when no constant is supplied (#2123)", () => {
const discriminated: Tool = {
name: "no-discriminator-supplied",
inputSchema: {
type: "object",
anyOf: [
{
type: "object",
properties: {
kind: { type: "string", const: "a" },
shared: { type: "number" },
},
},
{
type: "object",
properties: {
kind: { type: "string", const: "b" },
shared: { type: "number" },
},
},
],
},
};
// Both branches match vacuously, so no single branch is identified — but
// they agree about `shared`, so it is still coerced.
expect(convertToolParameters(discriminated, { shared: "4" })).toEqual({
shared: 4,
});
});
it("coerces a value whose schema lives on a root allOf branch (#2123)", () => { it("coerces a value whose schema lives on a root allOf branch (#2123)", () => {
const allOfTool: Tool = { const allOfTool: Tool = {
name: "allof-tool", name: "allof-tool",

View file

@ -50,8 +50,8 @@ describe("resolveRootUnion", () => {
"address", "address",
]); ]);
expect(branches[0].schema.required).toEqual(["note", "kind", "address"]); expect(branches[0].schema.required).toEqual(["note", "kind", "address"]);
expect(branches[0].ownFields).toEqual(["kind", "address"]); expect(branches[0].declaredFields).toEqual(["kind", "address"]);
expect(branches[1].ownFields).toEqual(["kind", "phone"]); expect(branches[1].declaredFields).toEqual(["kind", "phone"]);
}); });
it("strips the composition keywords it has absorbed", () => { it("strips the composition keywords it has absorbed", () => {
@ -106,16 +106,40 @@ describe("resolveRootUnion", () => {
); );
}); });
it("lets a branch's declaration win a name collision with the root", () => { it("merges a name collision rather than replacing the root's declaration", () => {
// Both apply, so the root's floor must survive the branch's ceiling.
const { branches } = resolveRootUnion({ const { branches } = resolveRootUnion({
type: "object", type: "object",
properties: { id: { type: "string" } }, properties: { id: { type: "number", minimum: 0 } },
required: ["id"], required: ["id"],
anyOf: [{ type: "object", properties: { id: { type: "number" } } }, SMS], anyOf: [
{ type: "object", properties: { id: { maximum: 10 } } },
{ type: "object", properties: { other: { type: "string" } } },
],
});
expect(branches[0].schema.properties?.id).toEqual({
type: "number",
minimum: 0,
maximum: 10,
}); });
expect(branches[0].schema.properties?.id).toEqual({ type: "number" });
// `required` unions rather than duplicating. // `required` unions rather than duplicating.
expect(branches[0].schema.required).toEqual(["id"]); expect(branches[0].schema.required).toEqual(["id"]);
// The branch declares the name, even though the base did too.
expect(branches[0].declaredFields).toEqual(["id"]);
});
it("declines a union whose branch contradicts the root's type for a field", () => {
// `string` under a base `number` describes a value that cannot exist, so
// flattening it would render one type and accept what the schema rejects.
const { branches } = resolveRootUnion({
type: "object",
properties: { id: { type: "number" } },
anyOf: [
{ type: "object", properties: { id: { type: "string" } } },
{ type: "object", properties: { other: { type: "string" } } },
],
});
expect(branches).toEqual([]);
}); });
describe("branch labels", () => { describe("branch labels", () => {
@ -221,6 +245,26 @@ describe("resolveRootUnion", () => {
expect(branches).toEqual([]); expect(branches).toEqual([]);
}); });
it("declines a member whose type rules objects out", () => {
// Tool arguments are a JSON object, so a `{ type: "string" }` member can
// never match — a fillable form for it would offer an invalid call.
const { branches } = resolveRootUnion({
type: "object",
anyOf: [EMAIL, { type: "string", properties: { a: {} } }],
});
expect(branches).toEqual([]);
});
it("declines a member whose properties are not an object", () => {
// Members arrive as `unknown`, so this is reachable and must not throw.
expect(
resolveRootUnion({
type: "object",
anyOf: [EMAIL, { type: "object", properties: null as unknown }],
}).branches,
).toEqual([]);
});
it("declines an empty union", () => { it("declines an empty union", () => {
expect(resolveRootUnion({ type: "object", anyOf: [] }).branches).toEqual( expect(resolveRootUnion({ type: "object", anyOf: [] }).branches).toEqual(
[], [],

View file

@ -125,6 +125,94 @@ export function convertParameterValue(
return value; return value;
} }
/**
* Which property schema types each supplied argument, for a tool whose
* `inputSchema` puts its fields on root composition branches (#2123).
*
* Reading only the root's `properties` finds no schema for any of them, so
* every value would be sent as the string the user typed — `--tool-arg count=3`
* reaching the server as `"3"`.
*
* The CLI has no branch picker, so which branch a call means is *inferred*:
* a discriminated union pins its discriminator with `const`, and the supplied
* arguments either match one branch's constants or they do not.
*
* - **Exactly one branch matches** — use its merged schema, which is also where
* a branch's specialization of a root-declared property lives.
* - **No branch is identifiable** — coerce only the names every branch that
* declares them types the *same* way. A name two branches type differently
* is left uncoerced rather than coerced by an arbitrary branch: `value` as a
* number in branch 0 and a boolean in branch 1 would otherwise turn
* `value=true` into `Number("true")`, i.e. `NaN`. Passing the raw string
* through is what this function did for every argument before it existed.
*/
function coercionProperties(
base: { properties?: Record<string, unknown> },
branches: {
schema: { properties?: Record<string, unknown> };
declaredFields: string[];
}[],
params: Record<string, string>,
): Record<string, unknown> {
if (branches.length === 0) {
return { ...base.properties };
}
const matching = branches.filter((branch) =>
matchesConstants(branch.schema.properties ?? {}, params),
);
if (matching.length === 1) {
return { ...matching[0].schema.properties };
}
const properties: Record<string, unknown> = { ...base.properties };
for (const name of new Set(branches.flatMap((b) => b.declaredFields))) {
// Only the branches that *declare* the name have an opinion about it — a
// branch that merely inherited the root's declaration is not a second,
// disagreeing vote. Read through the merged schema so a branch's
// specialization of a root property carries the root's keywords too.
const declarations = branches
.filter((branch) => branch.declaredFields.includes(name))
.map((branch) => branch.schema.properties?.[name]);
const types = new Set(declarations.map((schema) => typeNameOf(schema)));
if (types.size === 1) {
properties[name] = declarations[0];
} else {
delete properties[name];
}
}
return properties;
}
/** A schema's `type`, as a comparable string (`""` when it states none). */
function typeNameOf(schema: unknown): string {
if (typeof schema !== "object" || schema === null) return "";
const { type } = schema as { type?: unknown };
return Array.isArray(type)
? type.join(",")
: typeof type === "string"
? type
: "";
}
/**
* Whether every `const`-pinned property of a branch agrees with what was
* supplied. Values arrive as strings, so the comparison is stringified — which
* is exactly right for a discriminator, whose constants are string literals.
*/
function matchesConstants(
properties: Record<string, unknown>,
params: Record<string, string>,
): boolean {
return Object.entries(properties).every(([name, schema]) => {
if (typeof schema !== "object" || schema === null) return true;
const constValue = (schema as { const?: unknown }).const;
if (constValue === undefined) return true;
const supplied = params[name];
return supplied === undefined || supplied === String(constValue);
});
}
/** /**
* Convert string parameters to JSON values based on tool schema * Convert string parameters to JSON values based on tool schema
*/ */
@ -134,33 +222,10 @@ export function convertToolParameters(
): Record<string, JsonValue> { ): Record<string, JsonValue> {
const result: Record<string, JsonValue> = {}; const result: Record<string, JsonValue> = {};
// A property's schema can live on a root composition branch rather than on // A property's schema can live on a root composition branch rather than on
// the root itself (#2123). Reading only the root's `properties` there finds // the root itself (#2123); see `coercionProperties` for how the branch is
// no schema for any argument, so every value would be sent as the string the // identified when it does.
// user typed — `--tool-arg count=3` reaching the server as `"3"`. The union
// is flattened by merging every branch, because the CLI has no branch
// selection to consult: an argument named by one branch is coerced by that
// branch's schema, and a name two branches type differently keeps the first,
// which is no worse than the untyped passthrough it replaces.
const { base, branches } = resolveRootUnion(tool.inputSchema ?? {}); const { base, branches } = resolveRootUnion(tool.inputSchema ?? {});
// Start from the FIRST branch's merged schema rather than from the base: a const properties = coercionProperties(base, branches, params);
// branch may *specialize* a property the root also declares (root
// `count: {}`, branch `count: { type: "number" }`), and merging branch-last
// is what gives the typed declaration — starting from the base would keep the
// untyped one and send `count=3` as `"3"`, the very coercion this restores.
// Later branches then contribute only names not seen yet, so first-branch
// precedence matches what the web form seeds and what a name two branches
// type differently resolves to.
const properties: Record<string, unknown> = {
...(branches[0]?.schema.properties ?? base.properties),
};
for (const branch of branches.slice(1)) {
for (const [name, schema] of Object.entries(
branch.schema.properties ?? {},
)) {
properties[name] ??= schema;
}
}
for (const [key, value] of Object.entries(params)) { for (const [key, value] of Object.entries(params)) {
const paramSchema = properties[key] as ParameterSchema | undefined; const paramSchema = properties[key] as ParameterSchema | undefined;

View file

@ -64,8 +64,13 @@ export interface RootUnionBranch<T extends RootUnionSchema> {
schema: ResolvedSchema<T>; schema: ResolvedSchema<T>;
/** Human-readable name for the picker — see {@link branchLabel}. */ /** Human-readable name for the picker — see {@link branchLabel}. */
label: string; label: string;
/** Names this branch contributes that the base does not. */ /**
ownFields: string[]; * The names **this branch itself declares** — including one the base also
* declares, since a branch commonly *specializes* a root property (root
* `count: {}`, branch `count: { type: "number" }`) and a renderer that showed
* only the base's version would render the untyped one.
*/
declaredFields: string[];
} }
/** What {@link resolveRootUnion} decomposes a root schema into. */ /** What {@link resolveRootUnion} decomposes a root schema into. */
@ -92,34 +97,120 @@ function toBranch(value: unknown): RootUnionSchema | null {
return value as RootUnionSchema; return value as RootUnionSchema;
} }
/** Whether a schema's `type` permits an object instance. */
function admitsObject(schema: RootUnionSchema): boolean {
const { type } = schema;
if (type === undefined) return true;
return Array.isArray(type) ? type.includes("object") : type === "object";
}
/** A readable `properties` map, or `null` when the value is not one. */
function propertiesOf(schema: RootUnionSchema): Record<string, unknown> | null {
const { properties } = schema;
if (
typeof properties !== "object" ||
properties === null ||
Array.isArray(properties)
) {
return null;
}
return properties;
}
/** /**
* Whether a branch contributes anything a form can render. * Whether a branch is one a form can offer as an alternative.
* *
* A `{ type: "null" }` member — the nullable encoding {@link * Three ways it is not, all of which would put an option in the picker that
* ./nullableUnion.ts} owns — and a `$ref`-only or empty branch carry no * cannot be filled in:
* properties, so offering them as alternatives would produce a picker whose *
* options render nothing. * - **It carries no fields.** A `{ type: "null" }` member — the nullable
* encoding {@link ./nullableUnion.ts} owns — and a `$ref`-only or empty
* branch render nothing.
* - **`properties` is not an object.** Members arrive as `unknown`, so a
* malformed `properties: null` is reachable and would throw in `Object.keys`
* rather than being declined.
* - **Its `type` rules objects out.** Tool arguments are a JSON object, so a
* `{ type: "string", properties: {…} }` member can never match — rendering it
* as a fillable form would offer a call that cannot be valid.
*/ */
function hasFields(branch: RootUnionSchema): boolean { function isOfferable(branch: RootUnionSchema): boolean {
const properties = propertiesOf(branch);
return ( return (
branch.properties !== undefined && Object.keys(branch.properties).length > 0 properties !== null &&
Object.keys(properties).length > 0 &&
admitsObject(branch)
); );
} }
/**
* Merge two declarations of the same property name.
*
* Where a base and a branch both name a property, JSON Schema applies **both**
* — so keeping only the branch's would silently drop the base's constraints
* (root `{ minimum: 0 }` plus branch `{ maximum: 10 }` must keep the floor).
* A shallow union preserves every keyword only one side states, with the
* branch winning a keyword both state, being the more specific declaration.
*
* A *contradiction* is not resolvable this way and is not resolved here — see
* {@link contradicts}, which declines such a union outright.
*/
function mergeProperty(
baseProperty: unknown,
branchProperty: unknown,
): unknown {
const a = toBranch(baseProperty);
const b = toBranch(branchProperty);
if (a === null || b === null) {
return branchProperty;
}
return { ...a, ...b };
}
/**
* Whether a branch states a property `type` its base declaration rules out.
*
* The two are conjunctive, so `string` under a base `number` describes a value
* that cannot exist. No form can express that, and flattening it would render
* one of the two types and accept values the schema rejects — so the caller
* declines the union instead of picking a side.
*/
function contradicts(base: RootUnionSchema, branch: RootUnionSchema): boolean {
const baseProperties = propertiesOf(base) ?? {};
const branchProperties = propertiesOf(branch) ?? {};
return Object.entries(branchProperties).some(([name, branchProperty]) => {
const a = toBranch(baseProperties[name]);
const b = toBranch(branchProperty);
if (a === null || b === null) return false;
return (
typeof a.type === "string" &&
typeof b.type === "string" &&
a.type !== b.type
);
});
}
/** /**
* Merge a composition branch's `properties` and `required` into a base schema. * Merge a composition branch's `properties` and `required` into a base schema.
* *
* Both keywords are **conjunctive** where they meet: a value satisfying an * Both keywords are **conjunctive** where they meet: a value satisfying an
* `allOf` branch satisfies the base *and* the branch, and a value matching a * `allOf` branch satisfies the base *and* the branch, and a value matching a
* union branch must satisfy the root's own constraints too. So properties union * union branch must satisfy the root's own constraints too. So properties union
* (branch wins a name collision, being the more specific declaration) and * — a name both declare merged through {@link mergeProperty} rather than
* `required` unions. * replaced — and `required` unions.
*/ */
function mergeBranch<T extends RootUnionSchema>( function mergeBranch<T extends RootUnionSchema>(
base: T, base: T,
branch: RootUnionSchema, branch: RootUnionSchema,
): ResolvedSchema<T> { ): ResolvedSchema<T> {
const properties = { ...base.properties, ...branch.properties }; const baseProperties = propertiesOf(base) ?? {};
const branchProperties = propertiesOf(branch) ?? {};
const properties: Record<string, unknown> = { ...baseProperties };
for (const [name, branchProperty] of Object.entries(branchProperties)) {
properties[name] =
name in baseProperties
? mergeProperty(baseProperties[name], branchProperty)
: branchProperty;
}
const required = [ const required = [
...(base.required ?? []), ...(base.required ?? []),
...(branch.required ?? []).filter( ...(branch.required ?? []).filter(
@ -165,7 +256,7 @@ function branchLabel(
if (typeof branch.title === "string" && branch.title.trim() !== "") { if (typeof branch.title === "string" && branch.title.trim() !== "") {
return branch.title; return branch.title;
} }
const properties = branch.properties ?? {}; const properties = propertiesOf(branch) ?? {};
const constOf = (name: string): string | null => { const constOf = (name: string): string | null => {
const property = toBranch(properties[name]) as { const?: unknown } | null; const property = toBranch(properties[name]) as { const?: unknown } | null;
const value = property?.const; const value = property?.const;
@ -224,13 +315,15 @@ export function resolveRootUnion<T extends RootUnionSchema>(
const branches = members.map(toBranch); const branches = members.map(toBranch);
if ( if (
branches.length === 0 || branches.length === 0 ||
branches.some((branch) => branch === null || !hasFields(branch)) branches.some(
(branch) =>
branch === null || !isOfferable(branch) || contradicts(base, branch),
)
) { ) {
return { base, branches: [] }; return { base, branches: [] };
} }
const discriminatorProperty = schema.discriminator?.propertyName; const discriminatorProperty = schema.discriminator?.propertyName;
const baseFields = new Set(Object.keys(base.properties ?? {}));
return { return {
base, base,
branches: branches branches: branches
@ -240,9 +333,7 @@ export function resolveRootUnion<T extends RootUnionSchema>(
// `properties`/`required` off the branch, so the merge stays that way. // `properties`/`required` off the branch, so the merge stays that way.
schema: mergeBranch(base, branch), schema: mergeBranch(base, branch),
label: branchLabel(branch, index, discriminatorProperty), label: branchLabel(branch, index, discriminatorProperty),
ownFields: Object.keys(branch.properties ?? {}).filter( declaredFields: Object.keys(propertiesOf(branch) ?? {}),
(name) => !baseFields.has(name),
),
})), })),
}; };
} }