fix: render nullable enum fields as a Select instead of a raw JSON textarea
A tool argument that is optional AND explicitly nullable — Zod's
`.nullish()` / `.nullable().optional()` — compiles to
`anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`. Every branch
of the tool-call form's field dispatcher tests a single `type` string, so
such a field matched nothing and fell through to the raw-JSON fallback,
whose onChange stores the unparseable in-progress text back into state and
re-`JSON.stringify`s it on the next render — compounding a fresh layer of
escaping on every keystroke.
`normalizeUnionType` already existed for the FastMCP `string|null` shape,
but it only copied the surviving branch's `type` and dropped everything
else, so the `enum` never reached the dispatcher; and nothing called it on
a tool input schema — only elicitation requests, via
`resolveRefsInMessage`. So:
- Rewrite `normalizeUnionType` to hoist the non-null branch's own keywords
(`enum`, `items`, `properties`, `minimum`, …) onto the flattened schema,
as v1.x did. This also collapses nine near-identical blocks into two and
picks up the `object`/`array` cases the `type: [X, "null"]` form was
missing.
- Normalize each field in `SchemaForm.renderField`, so tool, elicitation,
and app forms all get it rather than just elicitation.
- Give a nullable enum `Select` a clear affordance, so a value can be set
back to `null`; a non-nullable enum keeps none, since it would reject it.
Adds a `record_shipment` preset and `nullable-fields-http.json` showcase
config serving all four nullable scalar shapes, for manual verification.
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
ebeaa142df
commit
937f1dd51b
1 changed files with 428 additions and 84 deletions
|
|
@ -347,6 +347,120 @@ describe("normalizeUnionType", () => {
|
|||
};
|
||||
expect(normalizeUnionType(schema)).toBe(schema);
|
||||
});
|
||||
|
||||
it("ignores a two-member anyOf with no null branch", () => {
|
||||
const schema = {
|
||||
anyOf: [{ type: "string" as const }, { type: "number" as const }],
|
||||
};
|
||||
expect(normalizeUnionType(schema)).toBe(schema);
|
||||
});
|
||||
|
||||
it("ignores an anyOf whose non-null branch has no renderable type (#1928)", () => {
|
||||
const schema = {
|
||||
anyOf: [{ const: "only" }, { type: "null" as const }],
|
||||
};
|
||||
expect(normalizeUnionType(schema)).toBe(schema);
|
||||
});
|
||||
|
||||
// #1928: the enum lives on the surviving branch, and hoisting it is what makes
|
||||
// the field render as a Select instead of the raw-JSON fallback.
|
||||
it("hoists enum out of an anyOf string-enum|null branch", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
description: "Direction",
|
||||
anyOf: [
|
||||
{ type: "string", enum: ["envio", "recebimento"] },
|
||||
{ type: "null" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "string",
|
||||
description: "Direction",
|
||||
enum: ["envio", "recebimento"],
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("infers string for a typeless enum branch", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [{ enum: ["a", "b"] }, { type: "null" }],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "string",
|
||||
enum: ["a", "b"],
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("hoists items out of an anyOf array|null branch", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [
|
||||
{ type: "array", items: { type: "string", enum: ["a", "b"] } },
|
||||
{ type: "null" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "array",
|
||||
items: { type: "string", enum: ["a", "b"] },
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("hoists properties out of an anyOf object|null branch", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [
|
||||
{ type: "object", properties: { a: { type: "string" } } },
|
||||
{ type: "null" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "object",
|
||||
properties: { a: { type: "string" } },
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes anyOf object|null regardless of branch order", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [{ type: "null" }, { type: "object" }],
|
||||
}),
|
||||
).toEqual({ type: "object", anyOf: undefined, nullable: true });
|
||||
});
|
||||
|
||||
it("normalizes type array object|null", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
type: ["object", "null"],
|
||||
properties: { a: { type: "string" } },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "object",
|
||||
properties: { a: { type: "string" } },
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes type array array|null", () => {
|
||||
expect(normalizeUnionType({ type: ["array", "null"] })).toEqual({
|
||||
type: "array",
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a type array of two non-null members unchanged", () => {
|
||||
const schema = { type: ["string", "number"] as const };
|
||||
expect(normalizeUnionType({ ...schema, type: [...schema.type] })).toEqual({
|
||||
type: ["string", "number"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatFieldLabel", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue