fix: address Copilot review — const precedence, TUI variant model, decline both-keyword unions

- rootUnion: decline a schema carrying BOTH oneOf and anyOf rather than
  reading one and silently dropping the other's constraints.
- SchemaForm: handle `const` before every type dispatch and for every
  constant type, so a numeric/boolean constant, or a schema carrying both
  `const` and `enum`, can no longer offer a forbidden value.
- collectSchemaDefaults / TUI initialValue: `const` outranks `default`,
  which is an annotation a schema may set to something its const rejects.
- convertToolParameters: resolve from the first branch's merged schema, so a
  branch's specialization of a root-declared property is what types the arg.
- TUI: ink-form keys values by field name across the whole form, so two
  branches' `kind` were one field and the later section's initial value
  decided what the earlier submitted. Branch fields now render under prefixed
  names behind a Variant select, and decodeFormValues translates back on
  submit, dropping every branch but the chosen one.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 01:01:54 -04:00
commit 2782ed75d1

View file

@ -142,10 +142,22 @@ export function convertToolParameters(
// 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 properties: Record<string, unknown> = { ...base.properties };
for (const branch of branches) {
for (const name of branch.ownFields) {
properties[name] ??= branch.schema.properties?.[name];
// 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;
}
}