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
2 changed files with 370 additions and 68 deletions

View file

@ -104,6 +104,25 @@ describe("JSON Utils", () => {
).toEqual({ note: "hi", count: 42, enabled: true });
});
it("prefers a branch's specialization of a root property (#2123)", () => {
const specializing: Tool = {
name: "specializing",
inputSchema: {
type: "object",
// The root declares the name but constrains nothing; the branch is
// what says it is a number.
properties: { count: {} },
anyOf: [
{ type: "object", properties: { count: { type: "number" } } },
{ type: "object", properties: { other: { type: "string" } } },
],
},
};
expect(convertToolParameters(specializing, { count: "3" })).toEqual({
count: 3,
});
});
it("coerces a value whose schema lives on a root allOf branch (#2123)", () => {
const allOfTool: Tool = {
name: "allof-tool",

View file

@ -63,13 +63,17 @@ describe("resolveRootUnion", () => {
expect(branches[0].schema.anyOf).toBeUndefined();
});
it("prefers oneOf when a schema carries both", () => {
const { branches } = resolveRootUnion({
it("declines a schema carrying both oneOf and anyOf", () => {
// Independent keywords a value satisfies together, so reading one and
// dropping the other would build a form missing real constraints.
const { base, branches } = resolveRootUnion({
type: "object",
properties: { note: { type: "string" } },
oneOf: [EMAIL],
anyOf: [EMAIL, SMS],
});
expect(branches).toHaveLength(1);
expect(branches).toEqual([]);
expect(Object.keys(base.properties ?? {})).toEqual(["note"]);
});
it("merges allOf branches unconditionally", () => {