fix: address Copilot review round 27 — unconstrained null branches, typed discriminators

- anyOfAdmitsNull recognizes a branch that constrains nothing (`{}`, or
  annotations only): it admits every value, null among them, so a field pinned
  to `const: null` beside such a union stays submittable. `admitsNull`
  cannot say that on its own — an unconstrained schema tells it nothing either
  way and it declines rather than guesses — but here the question is narrower.
- convertToolParameters sends a matched `const` as the schema's own typed
  value: `kind=2` selects a branch pinned to `const: 2` and was then sent
  `"2"`, which that same branch rejects. Only an exact match is
  substituted; anything else is the user's input and is left alone.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 10:07:35 -04:00
commit c8c5442dcf
2 changed files with 75 additions and 1 deletions

View file

@ -215,6 +215,36 @@ describe("JSON Utils", () => {
});
});
it("sends a non-string discriminator as its typed constant (#2123)", () => {
const numericallyPinned: Tool = {
name: "numerically-pinned",
inputSchema: {
type: "object",
oneOf: [
{
type: "object",
properties: { kind: { const: 1 }, a: { type: "string" } },
required: ["kind"],
},
{
type: "object",
properties: { kind: { const: 2 }, b: { type: "string" } },
required: ["kind"],
},
],
},
};
// `kind=2` selects the second branch, which then rejects `"2"` — the
// schema's own typed value is what goes on the wire.
expect(convertToolParameters(numericallyPinned, { kind: "2" })).toEqual({
kind: 2,
});
// Text that matches no constant is the user's input and is left alone.
expect(convertToolParameters(numericallyPinned, { kind: "9" })).toEqual({
kind: "9",
});
});
it("leaves an ambiguously typed argument uncoerced (#2123)", () => {
const ambiguous: Tool = {
name: "ambiguous",

View file

@ -759,6 +759,15 @@ describe("admitsNull", () => {
);
});
it("admits null when a sibling branch constrains nothing", () => {
// `{}` is the equivalent of `true` — it admits every value, null among
// them — so the pinned null stays reachable and its field submittable.
expect(admitsNull({ const: null, anyOf: [{}] })).toBe(true);
expect(admitsNull({ const: null, anyOf: [{ title: "anything" }] })).toBe(
true,
);
});
it("does not override a sibling that rejects null", () => {
// `const` is conjunctive with its siblings, not an override: both of
// these reject every value, so claiming nullability would let the