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:
parent
a0659b20d1
commit
84cd0c10f0
2 changed files with 635 additions and 107 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[],
|
||||
|
|
|
|||
Loading…
Reference in a new issue