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

@ -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(
[],