fix: address Copilot review round 34 — structured constants, non-required const controls
- A `const` may be an object or an array, with or without a `type`, and
`String({...})` is "[object Object]" — which no CLI argument can equal, so
the only value such a schema accepts never matched. Supplied text is parsed
and compared structurally through the resolver's own comparison (exported as
`sameJsonValue` so the two cannot disagree), for both branch selection and
the substitution.
- The TUI's one-option const control is never marked required: its option may
legitimately be the empty string, which ink-form's required gate can never
accept, so submission would not even reach `decodeFormValues`. The value is
fixed by the schema, and `missingRequiredFields` still validates the call.
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
ffa08d76f5
commit
ba91db97e2
5 changed files with 79 additions and 3 deletions
|
|
@ -503,6 +503,24 @@ describe("schemaToForm", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("never marks a const control required", () => {
|
||||||
|
// Its one option may be the empty string, which ink-form's required gate
|
||||||
|
// can never accept — the call would not even reach `decodeFormValues`.
|
||||||
|
const form = schemaToForm(
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { kind: { type: "string", const: "" } },
|
||||||
|
required: ["kind"],
|
||||||
|
},
|
||||||
|
"empty_const",
|
||||||
|
);
|
||||||
|
expect(form.sections[0]!.fields[0]).toMatchObject({
|
||||||
|
name: "kind",
|
||||||
|
type: "select",
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("renders a const outside a union the same way", () => {
|
it("renders a const outside a union the same way", () => {
|
||||||
const form = schemaToForm(
|
const form = schemaToForm(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -357,6 +357,12 @@ function buildFields(schema: JsonSchemaObject): FormField[] {
|
||||||
fields.push({
|
fields.push({
|
||||||
type: "select",
|
type: "select",
|
||||||
...baseField,
|
...baseField,
|
||||||
|
// Never required: the one option may legitimately be the empty string,
|
||||||
|
// which ink-form's required gate can never accept — submission would
|
||||||
|
// not even reach `decodeFormValues`, which reapplies the constant. The
|
||||||
|
// value is fixed by the schema, and `missingRequiredFields` still
|
||||||
|
// validates the decoded call.
|
||||||
|
required: false,
|
||||||
initialValue: String(pinned),
|
initialValue: String(pinned),
|
||||||
options: [{ label: String(pinned), value: String(pinned) }],
|
options: [{ label: String(pinned), value: String(pinned) }],
|
||||||
} as FormField);
|
} as FormField);
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,28 @@ describe("JSON Utils", () => {
|
||||||
).toEqual({ value: "true" });
|
).toEqual({ value: "true" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matches a structured const by parsing the supplied text (#2123)", () => {
|
||||||
|
const structured: Tool = {
|
||||||
|
name: "structured-const",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { tag: { const: { kind: "x", n: 1 } } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// `String({...})` is "[object Object]", which no argument can equal — so
|
||||||
|
// the only value the schema accepts would never have matched.
|
||||||
|
expect(
|
||||||
|
convertToolParameters(structured, { tag: '{"n":1,"kind":"x"}' }),
|
||||||
|
).toEqual({ tag: { kind: "x", n: 1 } });
|
||||||
|
// Text that is not that value, or not JSON at all, is left alone.
|
||||||
|
expect(convertToolParameters(structured, { tag: "{}" })).toEqual({
|
||||||
|
tag: "{}",
|
||||||
|
});
|
||||||
|
expect(convertToolParameters(structured, { tag: "nope" })).toEqual({
|
||||||
|
tag: "nope",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves an ambiguously typed argument uncoerced (#2123)", () => {
|
it("leaves an ambiguously typed argument uncoerced (#2123)", () => {
|
||||||
const ambiguous: Tool = {
|
const ambiguous: Tool = {
|
||||||
name: "ambiguous",
|
name: "ambiguous",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { Tool } from "@modelcontextprotocol/client";
|
||||||
import { normalizeNullableUnion } from "./nullableUnion.js";
|
import { normalizeNullableUnion } from "./nullableUnion.js";
|
||||||
import {
|
import {
|
||||||
narrowBySuppliedNames,
|
narrowBySuppliedNames,
|
||||||
|
sameJsonValue,
|
||||||
resolveRootUnion,
|
resolveRootUnion,
|
||||||
type RootUnionBranch,
|
type RootUnionBranch,
|
||||||
type RootUnionSchema,
|
type RootUnionSchema,
|
||||||
|
|
@ -253,10 +254,29 @@ function matchesConstants(
|
||||||
// read the inherited one and rule out every branch that pins that name.
|
// read the inherited one and rule out every branch that pins that name.
|
||||||
if (!Object.hasOwn(params, name)) return true;
|
if (!Object.hasOwn(params, name)) return true;
|
||||||
const supplied = params[name];
|
const supplied = params[name];
|
||||||
return supplied === undefined || supplied === String(constValue);
|
return supplied === undefined || suppliedMatchesConst(supplied, constValue);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the text a CLI argument carries is the value a `const` fixes.
|
||||||
|
*
|
||||||
|
* A primitive constant is compared as text, which is all a command line has. A
|
||||||
|
* structured one — a `const` may be an object or an array, with or without a
|
||||||
|
* `type` — is parsed first: `String({...})` is `"[object Object]"`, which no
|
||||||
|
* argument can equal, so the only value the schema accepts would never match.
|
||||||
|
*/
|
||||||
|
function suppliedMatchesConst(value: string, constValue: unknown): boolean {
|
||||||
|
if (constValue === null || typeof constValue !== "object") {
|
||||||
|
return value === String(constValue);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return sameJsonValue(JSON.parse(value), constValue);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert string parameters to JSON values based on tool schema
|
* Convert string parameters to JSON values based on tool schema
|
||||||
*/
|
*/
|
||||||
|
|
@ -288,7 +308,7 @@ export function convertToolParameters(
|
||||||
// user's input and is left alone.
|
// user's input and is left alone.
|
||||||
const pinned = (paramSchema as { const?: unknown } | undefined)?.const;
|
const pinned = (paramSchema as { const?: unknown } | undefined)?.const;
|
||||||
const converted =
|
const converted =
|
||||||
pinned !== undefined && String(pinned) === value
|
pinned !== undefined && suppliedMatchesConst(value, pinned)
|
||||||
? (pinned as JsonValue)
|
? (pinned as JsonValue)
|
||||||
: paramSchema
|
: paramSchema
|
||||||
? convertParameterValue(value, paramSchema)
|
? convertParameterValue(value, paramSchema)
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,17 @@ function canonicalJson(value: unknown): string {
|
||||||
return JSON.stringify(value) ?? "undefined";
|
return JSON.stringify(value) ?? "undefined";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Structural equality, via canonical JSON — enough for schema keyword values. */
|
/**
|
||||||
|
* Structural equality, via canonical JSON — enough for schema keyword values.
|
||||||
|
*
|
||||||
|
* Exported because a caller comparing a *supplied* value against a schema's
|
||||||
|
* `const` has to reach the same answer this module does; two implementations
|
||||||
|
* would disagree the moment one of them met an object.
|
||||||
|
*/
|
||||||
|
export function sameJsonValue(a: unknown, b: unknown): boolean {
|
||||||
|
return sameValue(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
function sameValue(a: unknown, b: unknown): boolean {
|
function sameValue(a: unknown, b: unknown): boolean {
|
||||||
return a === b || canonicalJson(a) === canonicalJson(b);
|
return a === b || canonicalJson(a) === canonicalJson(b);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue