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
4 changed files with 635 additions and 107 deletions

View file

@ -1,9 +1,5 @@
import { describe, it, expect } from "vitest";
import {
decodeFormValues,
schemaToForm,
VARIANT_FIELD,
} from "../src/utils/schemaToForm.js";
import { decodeFormValues, schemaToForm } from "../src/utils/schemaToForm.js";
describe("schemaToForm", () => {
it("returns an empty Parameters section when there is no schema", () => {
@ -445,7 +441,7 @@ describe("schemaToForm", () => {
"sms",
]);
expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([
VARIANT_FIELD,
"__variant",
"note",
]);
});
@ -468,7 +464,7 @@ describe("schemaToForm", () => {
it("offers a variant select listing the alternatives", () => {
const form = schemaToForm(UNION, "union_tool");
expect(form.sections[0]!.fields[0]).toMatchObject({
name: VARIANT_FIELD,
name: "__variant",
type: "select",
initialValue: "0",
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");
expect(form.sections[1]!.fields[0]).toMatchObject({
name: "__b0__kind",
type: "select",
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(
{
type: "object",
@ -509,14 +507,62 @@ describe("schemaToForm", () => {
},
"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", () => {
it("submits the chosen branch's fields under their real names", () => {
expect(
decodeFormValues(UNION, {
[VARIANT_FIELD]: "0",
__variant: "0",
note: "hi",
__b0__kind: "email",
__b0__address: "a@b.c",
@ -529,7 +575,7 @@ describe("schemaToForm", () => {
it("drops the branches the call is not making", () => {
expect(
decodeFormValues(UNION, {
[VARIANT_FIELD]: "1",
__variant: "1",
__b0__kind: "email",
__b0__address: "a@b.c",
__b1__kind: "sms",
@ -541,7 +587,7 @@ describe("schemaToForm", () => {
it("omits a branch field the user never filled", () => {
expect(
decodeFormValues(UNION, {
[VARIANT_FIELD]: "0",
__variant: "0",
__b0__kind: "email",
}),
).toEqual({ kind: "email" });
@ -550,7 +596,7 @@ describe("schemaToForm", () => {
it("falls back to the first branch on an unusable selection", () => {
expect(
decodeFormValues(UNION, {
[VARIANT_FIELD]: "nonsense",
__variant: "nonsense",
__b0__kind: "email",
}),
).toEqual({ kind: "email" });
@ -562,6 +608,44 @@ describe("schemaToForm", () => {
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", () => {

View file

@ -80,11 +80,46 @@ interface JsonSchemaObject {
* submit the second branch's discriminator. Prefixing each branch's fields and
* 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. */
function branchFieldName(branchIndex: number, name: string): string {
return `__b${branchIndex}__${name}`;
function branchFieldName(
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.
const { base, branches } = resolveRootUnion(schema);
const parameters = buildFields(base);
if (branches.length > 0) {
// 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 {@link decodeFormValues}, which drops the rest.
parameters.unshift({
if (branches.length === 0) {
return {
title,
sections: [{ title: "Parameters", fields: buildFields(base) }],
};
}
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",
name: VARIANT_FIELD,
name: variant,
label: "Variant",
required: true,
initialValue: "0",
@ -120,8 +176,9 @@ export function schemaToForm(
label: branch.label,
value: String(index),
})),
} as FormField);
}
} as FormField,
...buildFields({ properties: baseProperties, required: base.required }),
];
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
// a form that can never be submitted.
branches.forEach((branch, index) => {
const ownProperties = Object.fromEntries(
branch.ownFields.map((name) => [
branchFieldName(index, name),
const properties = Object.fromEntries(
branch.declaredFields.map((name) => [
branchFieldName(prefix, index, name),
branch.schema.properties?.[name],
]),
);
sections.push({
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:
* the base fields, plus the fields of the branch the {@link VARIANT_FIELD}
* select names, under their real property names.
* the base fields, plus the fields of the branch the variant select names,
* under their real property names.
*
* 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
* 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.
* shape this call is not making, and the user filled at most one section. The
* generated names are filtered by exact match rather than by prefix, so an
* 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>(
schema: JsonSchemaObject | null | undefined,
values: Record<string, T>,
): Record<string, T> {
const { branches } = resolveRootUnion(schema ?? {});
const { base, branches } = resolveRootUnion(schema ?? {});
if (branches.length === 0) {
return values;
return applyConstants(base.properties ?? {}, values);
}
const raw = values[VARIANT_FIELD];
const selected = Number(raw);
const { variant, prefix } = generatedNames(base, branches);
const selected = Number(values[variant]);
const branchIndex =
Number.isInteger(selected) && selected >= 0 && selected < branches.length
? selected
: 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> = {};
for (const [name, value] of Object.entries(values)) {
// Skip the select itself and every branch's prefixed field; the chosen
// branch's are re-added below under the names the schema declares.
if (name !== VARIANT_FIELD && !name.startsWith("__b")) {
if (!generated.has(name)) {
decoded[name] = value;
}
}
for (const name of branches[branchIndex]!.ownFields) {
const value = values[branchFieldName(branchIndex, name)];
for (const name of branch.declaredFields) {
const value = values[branchFieldName(prefix, branchIndex, name)];
if (value !== undefined) {
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. */
@ -215,6 +302,21 @@ function buildFields(schema: JsonSchemaObject): 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`
// 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
@ -279,17 +381,11 @@ function buildFields(schema: JsonSchemaObject): FormField[] {
}
// 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
// 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), 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) {
// A `const` never reaches here — it was rendered as its own one-option
// select above, which is also why `default` needs no precedence rule.
if (property.default !== undefined) {
(field as FormField & { initialValue?: unknown }).initialValue =
initialValue;
property.default;
}
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)", () => {
const allOfTool: Tool = {
name: "allof-tool",

View file

@ -50,8 +50,8 @@ describe("resolveRootUnion", () => {
"address",
]);
expect(branches[0].schema.required).toEqual(["note", "kind", "address"]);
expect(branches[0].ownFields).toEqual(["kind", "address"]);
expect(branches[1].ownFields).toEqual(["kind", "phone"]);
expect(branches[0].declaredFields).toEqual(["kind", "address"]);
expect(branches[1].declaredFields).toEqual(["kind", "phone"]);
});
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({
type: "object",
properties: { id: { type: "string" } },
properties: { id: { type: "number", minimum: 0 } },
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.
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", () => {
@ -221,6 +245,26 @@ describe("resolveRootUnion", () => {
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", () => {
expect(resolveRootUnion({ type: "object", anyOf: [] }).branches).toEqual(
[],