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

View file

@ -125,6 +125,94 @@ export function convertParameterValue(
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
*/
@ -134,33 +222,10 @@ export function convertToolParameters(
): Record<string, JsonValue> {
const result: Record<string, JsonValue> = {};
// 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
// no schema for any argument, so every value would be sent as the string the
// 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.
// the root itself (#2123); see `coercionProperties` for how the branch is
// identified when it does.
const { base, branches } = resolveRootUnion(tool.inputSchema ?? {});
// Start from the FIRST branch's merged schema rather than from the base: a
// 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;
}
}
const properties = coercionProperties(base, branches, params);
for (const [key, value] of Object.entries(params)) {
const paramSchema = properties[key] as ParameterSchema | undefined;

View file

@ -64,8 +64,13 @@ export interface RootUnionBranch<T extends RootUnionSchema> {
schema: ResolvedSchema<T>;
/** Human-readable name for the picker — see {@link branchLabel}. */
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. */
@ -92,34 +97,120 @@ function toBranch(value: unknown): RootUnionSchema | null {
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
* ./nullableUnion.ts} owns — and a `$ref`-only or empty branch carry no
* properties, so offering them as alternatives would produce a picker whose
* options render nothing.
* Three ways it is not, all of which would put an option in the picker that
* cannot be filled in:
*
* - **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 (
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.
*
* Both keywords are **conjunctive** where they meet: a value satisfying an
* `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
* (branch wins a name collision, being the more specific declaration) and
* `required` unions.
* — a name both declare merged through {@link mergeProperty} rather than
* replaced — and `required` unions.
*/
function mergeBranch<T extends RootUnionSchema>(
base: T,
branch: RootUnionSchema,
): 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 = [
...(base.required ?? []),
...(branch.required ?? []).filter(
@ -165,7 +256,7 @@ function branchLabel(
if (typeof branch.title === "string" && branch.title.trim() !== "") {
return branch.title;
}
const properties = branch.properties ?? {};
const properties = propertiesOf(branch) ?? {};
const constOf = (name: string): string | null => {
const property = toBranch(properties[name]) as { const?: unknown } | null;
const value = property?.const;
@ -224,13 +315,15 @@ export function resolveRootUnion<T extends RootUnionSchema>(
const branches = members.map(toBranch);
if (
branches.length === 0 ||
branches.some((branch) => branch === null || !hasFields(branch))
branches.some(
(branch) =>
branch === null || !isOfferable(branch) || contradicts(base, branch),
)
) {
return { base, branches: [] };
}
const discriminatorProperty = schema.discriminator?.propertyName;
const baseFields = new Set(Object.keys(base.properties ?? {}));
return {
base,
branches: branches
@ -240,9 +333,7 @@ export function resolveRootUnion<T extends RootUnionSchema>(
// `properties`/`required` off the branch, so the merge stays that way.
schema: mergeBranch(base, branch),
label: branchLabel(branch, index, discriminatorProperty),
ownFields: Object.keys(branch.properties ?? {}).filter(
(name) => !baseFields.has(name),
),
declaredFields: Object.keys(propertiesOf(branch) ?? {}),
})),
};
}