fix: render nullable and union argument fields correctly in the web form and the TUI - #2014
Conversation
…xtarea
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>
…he TUI Closes #2015, folded into #1928 rather than left as a follow-up. The TUI's `schemaToForm` has the same defect the web `SchemaForm` had: its local `JsonSchemaProperty` declares only `type?: string` — no `anyOf`, no array-valued `type` — so a `.nullish()` argument has no top-level `type` or `enum` for the dispatcher to read and falls to the `switch` default, losing its select. Milder than the web bug (a plain text field, not a corrupting JSON textarea), but the same class. Fixing it in a second place is the argument for not having the logic in a first place. So the collapse moves to `core/json/nullableUnion.ts` as `normalizeNullableUnion`, generic over a minimal `NullableUnionSchema` shape — the least a caller must expose to be *recognized* as a nullable union — so neither client has to adopt the other's schema type. Both form builders now share one copy and cannot drift on which schemas they render. - `core/json/nullableUnion.ts` — the shared collapse, plus tests at `clients/web/src/test/core/nullableUnion.test.ts` (the web suite is what gates `core/`). - `clients/web`: `schemaUtils.normalizeUnionType` is gone; `SchemaForm` and `resolveRefsInMessage` call the core function directly. - `clients/tui`: `schemaToForm` normalizes each property before dispatch, and `JsonSchemaProperty` grows the `anyOf` / array-`type` fields the encoding uses. Verified by driving the built TUI under a pty against the `nullable-fields-http.json` showcase server. Before, every argument of `record_shipment` rendered as a plain text field; after, `direction` is a select ("No value"), `quantity` an integer, `express` a boolean ("[Not set]"). Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
Pull request overview
Fixes nullable JSON Schema fields across web and TUI forms by sharing normalization logic in core/json.
Changes:
- Adds shared nullable-union normalization.
- Updates web and TUI field dispatchers with tests.
- Adds a reproducible nullable-fields test-server preset and documentation.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
core/json/nullableUnion.ts |
Adds shared union normalization. |
clients/web/src/components/groups/SchemaForm/SchemaForm.tsx |
Normalizes fields and enables nullable enum clearing. |
clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx |
Tests nullable web controls. |
clients/web/src/utils/schemaUtils.ts |
Replaces the web-only normalizer. |
clients/web/src/utils/schemaUtils.test.ts |
Removes superseded tests. |
clients/web/src/test/core/nullableUnion.test.ts |
Tests shared normalization behavior. |
clients/tui/src/utils/schemaToForm.ts |
Normalizes TUI field schemas. |
clients/tui/__tests__/schemaToForm.test.ts |
Tests nullable TUI controls. |
test-servers/src/test-server-fixtures.ts |
Adds the nullable-fields tool. |
test-servers/src/preset-registry.ts |
Registers the new preset. |
test-servers/configs/nullable-fields-http.json |
Adds a showcase server configuration. |
README.md |
Documents reproduction and expected behavior. |
AGENTS.md |
Documents the new core utility. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Four findings, all valid: 1. Returning `T` was unsound. The collapse genuinely changes two fields — `type` stops being an array, `anyOf` becomes `undefined` — so a caller whose `T` typed either that way could keep treating it so afterwards. The result is now `T | NormalizedNullableUnion<T>`, which states exactly that: `Omit<T, "type" | "anyOf">` plus a `RenderableType` `type`, a cleared `anyOf`, and `nullable`. The one assertion left is isolated in a `collapsed()` helper, needed only because `Omit` stays deferred while `T` is open. 2. A typeless `enum` does not imply strings. JSON Schema allows `enum: [1, 2]`, and inferring `"string"` for it would hand numbers to a `Select` declared `string[]`, and make the TUI submit `"1"` for `1`. Now only an all-string enum earns the inference; anything else stays on the JSON fallback. An explicit `type` is still authoritative. 3. `hasMissingRequiredFields` counted every null as missing, so clearing a *required* nullable enum — newly possible via the clear button — would disable submit on a value the schema permits. `required` constrains presence, not content, so null now counts as present exactly when the field's schema admits it. 4. `collectSchemaDefaults` read the raw union, so a default nested inside a nullable object's branch was displayed by the form but never seeded into the submitted values. It normalizes each property first, like the renderer does. Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 1 — all four addressed (e4e16bb)Mirroring the inline replies here, since those threads go hidden once the fix is pushed. All four findings were valid; two of them (2 and 3) were real bugs, not just type hygiene.
1 — unsound What this does not reach: the hoist cannot be made sound, because an 2 — 3 — required nullable fields. A bug my own clear button created: before this PR nothing could produce a 4 — defaults. The sharp edge here is that the form displays a hoisted default while the seeded values omit it, so the field silently submits empty — wrong data, not cosmetics. I normalized per-consumer rather than once up front: the two walk the schema differently (
|
… select a non-string enum Closes #2007. Two things, both surfaced while answering "does #1928's PR also fix #2007?". **#2007, and why it belongs in this PR.** `z.array(z.union([z.object(…), z.object(…)]))` gives an `items.anyOf` whose branches carry no top-level `const`, so every MultiSelect option was `String(item.const ?? "")` — the empty string, twice. Mantine *throws* on duplicate option values, greying out the whole tool panel. That is a pre-existing bug, but this branch makes it reachable from a new direction: a *nullable* array of object unions previously had no top-level `type` and fell to the JSON fallback, and now collapses to `type: "array"` with `items.anyOf` — straight into the crash. Shipping the collapse without this fix would widen an existing crash, so the two go together. Both shapes are covered by tests. `toConstOptions` now returns `null` unless every branch yields a distinct, non-empty value, and the field falls through to the JSON editor — which is the honest widget for a union of object shapes anyway. Applied to the `string`/`oneOf` path as well, which had the identical `?? ""` and the same duplicate-throw waiting on it. **Copilot round 2.** Round 1's fix kept "an explicit `type` is authoritative", so `{ type: "number", enum: [1, 2] }` still collapses with its enum hoisted. The TUI's dispatcher tests `property.enum` *before* the type switch, and `toSelectOptions` stringifies, so that reached a select that submits `"1"` for `1`. The select path is now gated on `isStringEnum` (exported from core for this), and a typed non-string enum falls through to its typed field: the enum constraint is lost, the value's type is not, which is the safer of the two losses. Pre-existing for a plain numeric enum; the collapse just made the nullable form reachable too. Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
clients/web/src/utils/jsonUtils.ts:163
normalizeNullableUnionis intentionally narrower than “admits null”: it leaves a three-member union unchanged. Consequently, a required field such asanyOf: [{ type: "string" }, { type: "number" }, { type: "null" }]renders the JSON fallback and parses an enterednull, but this check still marks it missing and disables App/elicitation submission even though the schema accepts it. Determine null admission independently of renderer normalization (including null branches in larger unions).
if (value === null) {
const fieldSchema = properties[field];
return fieldSchema === undefined
? true
: normalizeNullableUnion(fieldSchema).nullable !== true;
test-servers/src/preset-registry.ts:97
- The new preset path is not exercised by an automated test: the added web/TUI tests construct schemas directly, so neither
record_shipment/nullable_fieldsresolution nor the Zod-generated showcase schema is verified. Add a resolver/config test that loads this preset and asserts the nullable schema shape; otherwise a registry typo or Zod output change can silently break the documented reproduction server.
case "record_shipment":
case "nullable_fields":
return createNullableFieldsTool();
…reset coverage
Three findings (one inline, two in the suppressed block), all valid.
1. `toConstOptions` still stringified every non-null `const`, so
`items: { anyOf: [{ const: 1 }, { const: 2 }] }` submitted `["1"]` where
the schema says `[1]`. Same wrong-type-on-the-wire problem that keeps a
numeric `enum` off the select path, so it gets the same answer: only a
string `const` is selectable, anything else falls to the JSON editor
where the value keeps its type.
2. `hasMissingRequiredFields` derived null admission from the *renderer's*
collapse, which is narrower on purpose — it only flattens a two-member
union. So `anyOf: [string, number, null]` renders through the JSON
fallback (where `null` is typeable) yet was still marked missing,
disabling submit on a value the schema accepts. Null admission is a
validity question, not a rendering one, so it is now its own predicate:
`admitsNull` in core, recognizing `nullable`, `type: "null"`,
`type: [..., "null"]`, and a null branch anywhere in an `anyOf`/`oneOf`
of any size.
3. Nothing exercised the `record_shipment` preset — the web and TUI tests
build the `anyOf` shape by hand, which verifies the renderers but
*assumes* the premise the whole fix rests on: that Zod's `.nullish()`
emits `anyOf: [<branch>, { type: "null" }]` with the enum on the branch.
A Zod output change or a registry typo would leave every unit test green
while the documented reproduction server quietly stopped reproducing.
Added an integration test that boots the preset over HTTP, asserts the
wire shape, and runs it back through the collapse — following the
`duplicate-tool-names.test.ts` precedent, which exists for the same
reason (#1957).
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 3 — all three addressed (34ba825)One inline comment plus two in the suppressed block, which are easy to miss and were both real. All three valid. 1 — non-string 2 — null admission was derived from the renderer (suppressed). The sharper half of the review. The underlying mistake was conflating two questions. "Can this become one widget?" is a rendering question with good reasons to be conservative. "Does this permit null?" is a validity question with no such limit. They now have separate functions: 3 — the preset had no automated coverage (suppressed). The web and TUI tests build the Added
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (2)
core/json/nullableUnion.ts:219
- The
type: [T, "null"]path leaves top-level enum values untouched. A valid nullable enum is commonly encoded as{ type: ["string", "null"], enum: ["envio", "recebimento", null] }; after this collapse, the web dispatcher passes the remainingnullto MantineSelectas option data, while the TUI'sisStringEnumcheck rejects the enum and renders a plain text field. Handle the enum'snullsentinel when collapsing or in both dispatchers so this second supported nullable encoding also produces the intended select without invalid option data.
if (
Array.isArray(schema.type) &&
schema.type.length === 2 &&
schema.type.includes("null")
) {
const type = schema.type.find((member) => member !== "null");
if (isRenderableType(type)) {
return collapsed(schema, undefined, type);
clients/web/src/test/integration/mcp/nullable-fields.test.ts:55
- This test bypasses both new integration points by constructing
createNullableFieldsTool()directly. Consequently, the preset registry could misspellrecord_shipment, ornullable-fields-http.jsoncould reference an invalid preset, and this purported end-to-end test would still pass—the exact regression its header says it pins. Load and resolve the checked-in config here (asduplicate-tool-names.test.tsdoes) before starting the server so the new registry/config wiring is actually covered.
const started = createTestServerHttp({
serverInfo: createTestServerInfo("nullable-fields-test", "1.0.0"),
tools: [createNullableFieldsTool()],
… wiring
Copilot round 4: no new visible comments, two in the suppressed block. Both
valid.
1. The `type: [T, "null"]` encoding keeps its keywords at the top level, so a
nullable enum written that way carries the null *inside* the list:
`{ type: ["string", "null"], enum: ["envio", "recebimento", null] }`. The
collapse left it there, which broke both renderers in different ways — the
web dispatcher handed `null` to Mantine as option data, and the TUI's
all-strings check rejected the whole enum and fell back to a plain text
field. Neither is the dropdown the schema asks for.
The collapse now strips a `null` member whenever it flattens, since that
fact has already moved onto `nullable`. An enum of nothing but `null`
drops out entirely: it offers no value a dropdown could show, so the plain
widget serves the field better than an empty select. Covered in core and
end-to-end in both dispatchers.
2. The new integration test claimed in its own header to pin the registry and
config wiring, then called `createNullableFieldsTool()` directly — so a
misspelt preset case or a config naming a dead preset would have left it
green. A fair "this does not test what it says" catch. It now resolves the
checked-in `nullable-fields-http.json` through `loadConfig` →
`resolveConfig`, matching `duplicate-tool-names.test.ts`, and asserts the
resolved tool names before booting the server.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 4 — both addressed (8356033)No new visible comments this round; both findings were in the suppressed block, and both were real. 1 — the enum's { "type": ["string", "null"], "enum": ["envio", "recebimento", null] }Collapsing The collapse now strips a 2 — the new integration test did not test what its header claimed. Fair, and the more useful of the two. I wrote that the test pins the registry and config wiring, then built the server with It now goes through
|
… type decide
Copilot round 7: both findings are the same principle applied one level
further — JSON Schema siblings are conjunctive, and this code was treating
them as alternatives.
1. The hoist is a spread, so a branch keyword *replaced* the wrapper's. That
is not a conjunction: a wrapper `enum: ["a"]` around a branch
`enum: ["a", "b"]` means `"a"`, but the spread produced `["a", "b"]` and
the dropdown would have offered — and submitted — a `"b"` the schema
rejects. `type`, bounds, and the object keywords fail the same way.
The collapse now declines when wrapper and branch carry different values
for the same validation keyword, and the field renders through the JSON
editor with its schema intact. Intersecting them properly is a much larger
surface, and a subtly wrong intersection would be worse than none.
Identical values are not a conflict, so the ordinary redundant-`type`
shape still collapses; metadata (`title`, `description`, `default`,
`enumNames`) never conflicts, since nothing validates against it.
2. `admitsNull` let a `{ type: "null" }` branch outvote an explicit
non-null top-level `type`. `{ type: "string", anyOf: [..., { type:
"null" }] }` rejects null — the value must satisfy the type *and* the
union — so an explicit `type` now decides instead of falling through to
the branch scan.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 7 — both addressed (987279b)One visible comment, one suppressed. They are the same principle at two levels: JSON Schema siblings are conjunctive, and this code was treating them as alternatives. 1 — the hoist dropped wrapper constraints. The merge is a spread, and a spread is replacement. A wrapper Worth being clear about the provenance: "the branch wins on any shared key" came from v1.x, and I carried it over and documented it as intentional. It is only safe because the wrapper in practice holds The collapse now declines when wrapper and branch hold different values for the same validation keyword, leaving the field on the JSON editor with its schema intact. I chose declining over intersection deliberately: intersecting means handling Two things keep the guard from over-firing: identical values are not a conflict (so the ordinary redundant- 2 — an explicit Together with round 6's sibling-
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
core/json/nullableUnion.ts:141
- This conflict whitelist omits validation applicators such as
oneOf,allOf, andnot. If both the wrapper and surviving branch defineoneOf, the branch spread replaces the wrapper constraint; for example, a wrapper allowing only"a"can normalize to a Select offering branch value"b", which the original schema rejects. Please block conflicting non-annotation keys conservatively (or cover the full validation/applicator set) rather than relying on this incomplete whitelist.
const VALIDATION_KEYWORDS = [
"type",
"enum",
"const",
"items",
clients/web/src/components/groups/SchemaForm/SchemaForm.tsx:250
- Invalid draft text is propagated as
undefined, but no validity state reaches the submit controls. Optional invalid fields therefore leave Open/Submit enabled and are silently omitted; inToolDetailPanel, Execute is gated only byisExecuting, so even a required invalid JSON field can be sent without the argument. The visible error does not prevent the incorrect request. Expose draft validity to all SchemaForm callers and disable their action while any draft is invalid.
onChange={(text) => {
setDraft(text);
onChange(parseJsonDraft(text));
| return ( | ||
| branch.type === "null" || | ||
| (Array.isArray(branch.type) && branch.type.includes("null")) | ||
| ); |
There was a problem hiding this comment.
All three false positives are real — fixed in 2fdbd19, and I took the "conservatively decline" option you offered rather than trying to evaluate them.
{ type: "null", const: "x" }is unsatisfiable: the branch check now runs the same sibling test on the branch itself, so naming null is no longer sufficient.notandallOfat the top level can rule null out; both now return false outright.oneOfrequires exactly one branch to match, so a null branch does not by itself meannullvalidates. Dropped from the scan entirely rather than half-evaluated.
The asymmetry is what decides the direction: under-claiming nullability costs a clear button and treats a valid null as missing; over-claiming lets the form emit a value the schema rejects. For an inspector the second is the worse failure, so every shape this module cannot evaluate now answers false.
This round also made me change the shape of the other guard rather than extend it — see the suppressed comment about VALIDATION_KEYWORDS. You were right that it was an incomplete whitelist, and more importantly it was incomplete in the failing-open direction. It is now an allowlist of annotation keywords, so an unrecognized keyword declines instead of being dropped. That is the third round in a row on conjunctive semantics, and the pattern was that each fix was a new special case; inverting both defaults to fail-closed is the answer that does not need a fourth.
…schemas
Copilot round 8 opened the same conjunctive-semantics seam a third time, so
this stops patching cases and inverts the defaults instead. Both guards now
fail CLOSED: anything this module cannot evaluate sends the field to the JSON
editor rather than to a widget built from a partial reading.
1. The wrapper-conflict check enumerated the *validation* keywords and declined
when two of them disagreed — which meant any keyword nobody listed
(`oneOf`, `allOf`, `not`, `$ref`, a future vocabulary) was silently treated
as safe to drop. That whitelist fails open, and open means rendering a
widget for a constraint that is not the schema's. It is replaced by an
allowlist of *annotation* keywords: the collapse declines whenever the
wrapper carries anything beyond annotations and the union itself. Stricter
than the old equality test — a wrapper restating its branch's `type` now
declines too — which costs a dropdown on a rare redundant shape and buys a
rule with no gap in it.
2. `admitsNull` returned true for shapes it had not actually evaluated:
`not: { type: "null" }` rules null out, `allOf` can add a member that does,
`oneOf` requires *exactly one* branch to match (so a null branch does not
by itself mean null validates), and a branch like `{ type: "null",
const: "x" }` is unsatisfiable. All now answer false. Under-claiming
nullability costs a clear button; over-claiming lets the form emit a value
the schema rejects, so the asymmetry decides it.
Note the third finding — draft validity never reaching the submit controls —
is NOT fixed here, and it is worse than I described last round: `ToolDetailPanel`
gates Execute on `isExecuting` alone and never calls `hasMissingRequiredFields`,
so my earlier claim that required fields were already covered was wrong for the
Tools tab. Corrected on #2020, which is where the fix belongs.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 8 — two fixed, one deferred with a correction (2fdbd19)Rounds 5–8 have all been the same conjunctive-semantics seam opened one level deeper each time, and each fix was a new special case. So this round changes the shape of both guards rather than adding another: they now fail closed. Anything this module cannot evaluate sends the field to the JSON editor instead of to a widget built from a partial reading. 1 — the conflict whitelist was incomplete in the failing-open direction. It enumerated validation keywords and declined when two disagreed, which meant any keyword nobody listed — Stricter than the old equality test — a wrapper merely restating its branch's 2 — The asymmetry decides the direction: under-claiming nullability costs a clear button and treats a valid 3 — deferred, and a correction to what I said in round 6The suppressed comment about draft validity not reaching the submit controls is not fixed here, and it is worse than I described. I claimed a required field was already gated, because its value is Two distinct gaps, both now recorded on #2020 with the corrected framing:
Both are behavior changes to the submit path across four components, which is beyond what a fix for #1928/#2007/#2015 should carry — but the corrected severity is on the issue rather than left as I first wrote it.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (4)
core/json/nullableUnion.ts:306
- The documentation says
oneOfnull branches are recognized, but the implementation deliberately returnsfalsewheneveroneOfis present. Document the conservative behavior so callers do not rely on null admission that the function never reports.
* Recognizes every encoding the collapse does, plus the ones it declines:
* `nullable: true`, `type: "null"`, `type: [..., "null"]`, and a `"null"`
* branch anywhere in an `anyOf` or `oneOf` of any size.
core/json/nullableUnion.ts:335
- An explicit type that includes
nulldoes not by itself prove the whole schema accepts null; sibling keywords remain conjunctive.{ type: ["string", "null"], anyOf: [{ type: "string" }] }rejects null, but this returnstrue, so required-field gating accepts an invalid null. IfanyOfis present, verify it rather than returning immediately, and update the test that currently expects this false positive.
if (schema.type !== undefined) {
return typeNamesNull(schema.type);
}
core/json/nullableUnion.ts:351
- A null-typed branch can still be unsatisfiable because of its own nested
anyOf. For example,{ type: "null", anyOf: [{ type: "string" }] }currently passes this check, making a normalized Select clearable even though the original schema rejects null. Treat nestedanyOflike the other unevaluated applicators and add a regression test.
return (
!nullExcludedBySiblings(branchSchema) &&
branchSchema.not === undefined &&
branchSchema.allOf === undefined &&
branchSchema.oneOf === undefined
);
core/json/nullableUnion.ts:412
- The
type: [T, "null"]path can also carry an independent siblinganyOf, butcollapsed()always clearsanyOf. For example,{ type: ["string", "null"], anyOf: [{ const: "a" }] }is widened to an unconstrained string field. Conservatively leave this compound schema unnormalized instead of dropping its constraint, with a regression test.
if (
Array.isArray(schema.type) &&
schema.type.length === 2 &&
schema.type.includes("null")
) {
… docs
Copilot round 9: four findings, all of them places where round 8's fail-closed
inversion was the right idea applied incompletely. The rule was only enforced
at the positions I happened to be editing; it now holds everywhere a schema is
inspected.
1. `admitsNull` returned early on an explicit `type`, ignoring a sibling
`anyOf` — but the two are conjunctive, so `{ type: ["string", "null"],
anyOf: [{ type: "string" }] }` admits no null at all. A sibling union now
withholds the claim.
2. A null-typed branch could be unsatisfiable via its own nested `anyOf`
(`{ type: "null", anyOf: [{ type: "string" }] }`). The branch check tested
`not`/`allOf`/`oneOf` but not `anyOf`; it now treats a nested union as
equally opaque.
3. The `type: [T, "null"]` collapse path had no applicator guard at all —
`collapsed` clears `anyOf` unconditionally and the renderers ignore the
rest, so `{ type: ["string", "null"], anyOf: [{ const: "a" }] }` was widened
into an unconstrained string field. That path now declines the same way the
`anyOf` path does.
4. The `admitsNull` docstring still advertised `oneOf` support that round 8
deliberately removed. Rewritten to state what is recognized, what is
declined and why, and the asymmetry that settles the direction.
The three code fixes share one helper (`hasOpaqueApplicator`) rather than
repeating the condition, so the next position that needs it cannot drift.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 9 — all four addressed (274a328)No visible comments; four in the suppressed block. All valid, and all of them the same defect: round 8's fail-closed inversion was the right idea enforced only at the positions I happened to be editing. The rule now holds everywhere a schema is inspected. 1 — an explicit 2 — a null-typed branch can be unsatisfiable via its own nested 3 — the 4 — the The three code fixes now share one On the shape of this reviewRounds 5–9 have all worked the same seam: this module inspects JSON Schema without evaluating it, and every guard is a place that can be incomplete. Two things are worth recording rather than leaving implicit. First, the fixes have converged. Rounds 5–7 were new special cases each time; round 8 changed the defaults to fail closed; round 9 is that same rule applied at the three positions I missed, sharing a helper so there is no fourth set of positions to miss. That is a different trajectory from whack-a-mole. Second, the remaining risk is bounded in the safe direction. Every unresolved case now renders through the JSON editor with its constraints intact rather than through a widget built from a partial reading. The cost is a dropdown the user does not get on an exotic schema; the thing avoided is an inspector that misreports what it sends.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (1)
core/json/nullableUnion.ts:427
- A non-null branch can itself contain
anyOf, but this guard still collapses it. Incollapsed(), the branch's nestedanyOfoverwrites the outer one during the spread and is then set toundefined, widening (for example){ anyOf: [{ type: "string", anyOf: [{ const: "a" }] }, { type: "null" }] }into an unconstrained nullable string field. Conservatively decline normalization when the surviving branch has its ownanyOf, just as the type-array path already does for sibling applicators.
if (nullBranch && branch && !wrapperCarriesConstraints(schema)) {
…dicate
Copilot round 10: one finding, the last position of the rule the previous two
rounds established. A *non-null* branch can carry its own `anyOf`, and the
hoist drops it — the branch's `anyOf` overwrites the wrapper's in the spread
and is then cleared, so `{ anyOf: [{ type: "string", anyOf: [{ const: "a" }] },
{ type: "null" }] }` widened into an unconstrained nullable string.
Rather than add a fourth inline condition, the four sites that ask "does this
schema compose something I cannot evaluate?" now share one predicate,
`hasUnevaluatedComposition` (an opaque applicator, or a nested `anyOf`):
- the surviving branch in the `anyOf` collapse (this round's finding),
- the null branch in `admitsNull`,
- the `type: [T, "null"]` collapse path,
- and, via `hasOpaqueApplicator`, the top of `admitsNull`.
Each of those was missed independently across rounds 8-10 for the same reason:
the guard existed but was spelled out inline at each site and drifted. Sharing
the predicate is the part that stops a fifth site from repeating it.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 10 — addressed (562709a)One suppressed finding, valid: the non-null branch can carry its own Rather than add a fourth inline condition, the four sites that ask "does this schema compose something I cannot evaluate?" now share one predicate,
Every one of those was missed independently, for the same reason each time: the guard existed but was written inline at each site, so fixing one did not fix the others. Sharing the predicate is the part that matters more than this round's individual fix. Where this leaves the moduleTen rounds, 22 findings, all valid. The arc is worth stating plainly because it is not the same thing repeating:
What remains unhandled is unhandled deliberately and visibly: this module recognizes two nullable-union encodings and refuses everything else, and "refuses" means the field renders through the JSON editor with its constraints intact. The failure mode left is a user not getting a dropdown on an exotic schema. The failure mode avoided is an inspector that shows a widget for a constraint the server did not send. Two items remain open and are tracked rather than carried here: #2020 (draft validity never reaches the submit controls, plus
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (3)
core/json/nullableUnion.ts:270
- An empty enum also needs to stop normalization. In
anyOf: [{ type: "string", enum: [] }, { type: "null" }], the original schema permits onlynull, but this path returnsunchanged, producing a clearableSelectwith no options. A required field then has no interaction that can write the explicitnullneeded to satisfy it. Conservatively decline the collapse for an empty enum just as for[null].
if (kept.length === members.length) {
return { kind: "unchanged" };
core/json/nullableUnion.ts:281
- When
enumNameswas already length-mismatched, preserving it can accidentally make it look aligned after null entries are removed. For example,enum: [null, "b"]withenumNames: ["None"]becomesenum: ["b"]andenumNames: ["None"], so both renderers now labelbas “None” instead of ignoring the malformed labels. SetenumNamestoundefinedunless it was parallel before filtering.
const aligned =
Array.isArray(names) && names.length === members.length
? kept.map((entry) => names[entry.index])
: names;
core/json/nullableUnion.ts:206
$refis another unevaluated composition keyword, but this predicate does not reject it. A branch such as{ type: "string", $ref: "#/$defs/integerOnly" }can be unsatisfiable, yet it is flattened into a nullable text field; similarly,admitsNullcan acceptnulldespite a sibling$refruling it out, enabling submission of a schema-invalid required value. Treat$refas opaque anywhere this helper is used.
function hasOpaqueApplicator(schema: NullableUnionSchema): boolean {
return (
schema.not !== undefined ||
schema.allOf !== undefined ||
schema.oneOf !== undefined
);
…opaque
Copilot round 11. Two of the three are the enum-strip logic this PR added
misbehaving on degenerate input; the third is one more keyword for the
fail-closed rule.
1. An empty `enum` permits nothing at all, but the strip reported "unchanged"
(nothing was removed) and the collapse built a Select with no options — a
required field then had no interaction able to supply a value. It now
reaches the same conclusion as `[null]`: no selectable member, so decline.
The outcome is renamed `unselectable`, since "only-null" no longer describes
both routes to it.
2. Filtering `null` out of `enum` shortens it, which could make an ALREADY
mismatched `enumNames` come out the same length — so `enum: [null, "b"]`
with `enumNames: ["None"]` started labelling `b` as "None", turning labels
both renderers had correctly ignored into a confident mislabel. A list that
was not parallel before filtering is now dropped rather than kept.
3. `$ref` joins the opaque set. It is never resolved here and applies alongside
its siblings, so `{ type: "string", $ref: "#/$defs/intOnly" }` can be
unsatisfiable. The wrapper path already declined on it by virtue of the
annotation allowlist; this extends the same rule to branches, the
`type: [T, "null"]` path, and `admitsNull`.
No round 12 was requested: the review loop was stopped at the user's direction.
Signed-off-by: cliffhall <cliff@futurescale.com>
|
Round 11 findings addressed in 18053d4. No round 12 requested — the review loop is stopped at the maintainer's direction, so this is the final state of the branch from my side. Two of the three were the enum-strip logic this PR introduced misbehaving on degenerate input, which is worth naming rather than burying:
For the record, since I claimed otherwise after round 10: I said rounds 9–10 had closed the source of repeat findings. Round 11 disproved that — three more, two of them in code this PR added. The fail-closed direction has held (nothing found since round 8 has been a case of the form rendering something the schema forbids by default; they have been positions the rule had not reached), but "converged" was my inference, not an observation, and it was wrong. Known-open items are tracked, not hidden: #2020 (draft validity never reaches the submit controls, and
|
Closes #1928
Closes #2015
Closes #2007
The bug
A tool argument that is optional and explicitly nullable — Zod's
.nullish()/.nullable().optional()— compiles to{ "anyOf": [{ "type": "string", "enum": ["envio", "recebimento"] }, { "type": "null" }] }Every branch of the tool-call form's field dispatcher (
SchemaForm.renderField) tests a singletypestring, so a field shaped like that matches nothing and falls through to the raw-JSONJsonInputfallback. That fallback conflates display and storage: itsonChangedoesJSON.parse(text)and, on failure, stores the raw unparsed text, which the next render re-JSON.stringifys — adding a layer of escaping per keystroke until the value is unusable.The fix
normalizeUnionTypealready existed inutils/schemaUtils.tsfor the FastMCPstring|nullshape, but it fell short on both counts:typeand dropped everything else, so theenumnever reached the dispatcher — a nullable enum normalized to a bare{ type: "string" }and rendered as a text box;resolveRefsInMessage, i.e. elicitation requests.So:
core/json/nullableUnion.tsasnormalizeNullableUnion, and hoists the non-null branch's own keywords (enum,enumNames,items,properties,minimum, …) onto the flattened schema, matching what v1.x did. This also collapses nine near-identical blocks into two, and picks up theobject/arraycases thetype: [X, "null"]form was missing.SchemaForm.renderFieldnormalizes each field before dispatching, so tool, elicitation, and MCP App forms all benefit rather than just elicitation. (The web-onlyschemaUtils.normalizeUnionTypeis gone — both its callers now use the core function.)Selectgets a clear affordance, so a chosen value can be set back tonull. A non-nullable enum keeps none — clearing emitsnull, which such a schema would reject.Nothing else changes: a union of two real types, a three-member
anyOf, or a branch with no renderable type still falls through to the JSON input, which is the honest representation of a shape the form cannot model.Screenshots
Driven against the new
nullable-fields-http.jsonshowcase server (below), whoserecord_shipmenttool declares all four nullable scalar shapes.Before — every nullable field is a raw JSON textarea, including the enum:
Before, after typing
enviointodirection— the compounding escape from the issue:After —
directionis a real Select,referencea text input,quantitya number input (with the branch'sminimum/maximumhoisted),expressa checkbox:Test server
Adds a
record_shipmentpreset and anullable-fields-http.jsonshowcase config (README row + section), so the shape is reproducible by hand instead of needing a hand-rolled server.The TUI had the same defect — fixed here, not deferred
clients/tui/src/utils/schemaToForm.tsdispatches the same way, and its localJsonSchemaPropertydeclared onlytype?: string— noanyOf, no array-valuedtype— so a.nullish()argument fell to theswitchdefault and lost its select. Milder than the web bug (a plain text field, not a corrupting JSON textarea), but the same class.Fixing it in a second place is the argument for not having the logic in a first place, so rather than duplicate it the collapse moved into
core/json/(point 1 above) and both form builders now share one copy.normalizeNullableUnionis generic over a minimalNullableUnionSchema— the least a caller must expose to be recognized as a nullable union — so neither client has to adopt the other's schema type.Verified by driving the built TUI under a pty against the same showcase server. Before, every argument of
record_shipmentrenders as a plain text field:After —
directionis a select (No value),expressa boolean ([Not set]):(No image: Ink needs a real TTY, which is also why this repo's own
smoke:tuiis local-only. These are the captured terminal frames.)#2007 comes along, because this PR widens it
z.array(z.union([z.object(…), z.object(…)]))produces anitems.anyOfwhose branches have no top-levelconst, so every MultiSelect option wasString(item.const ?? "")— the empty string, twice. Mantine throws on duplicate option values, greying out the whole tool panel (#2007).That bug predates this branch, but the collapse gives it a new way in: a nullable array of object unions had no top-level
typebefore, so it fell to the safe JSON fallback; now it collapses totype: "array"withitems.anyOfand lands straight on the crash. Shipping the collapse without this fix would widen an existing crash, so they belong together. Verified both shapes crash on the intermediate commit, and both are covered by tests now.toConstOptionsreturnsnullunless every branch yields a distinct, non-empty value, and the field then falls through to the JSON editor — the honest widget for a union of object shapes anyway. Thestring+oneOfpath had the identical?? ""and the same duplicate-throw waiting on it, so it is gated the same way.Testing
clients/web/src/test/core/nullableUnion.test.ts— the collapse's own suite (the web project is what gatescore/): cases for enum/items/properties hoisting, the typeless-enum branch, branch order, theobject/arraytype-array forms, and the three shapes that must stay untouched.clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx— aSchemaForm nullable unionsblock covering the Select (and its clear-to-null), the absence of a clear button on a plain enum, and the text/number/checkbox/MultiSelect/nested-object/JSON-fallback paths.clients/tui/__tests__/schemaToForm.test.ts— anullable unionsblock covering the select, the remaining nullable scalars (with the branch'sminimum/maximumhoisted), the nullable array-of-enum, thetype: [T, "null"]encoding, and the real-union case that must stay a plain field.SchemaForm.test.tsxalso covers SchemaForm crashes with Mantine "Duplicate options … value """ when array items use anyOf of object schemas #2007: the plain and nullable array-of-object-unions, a duplicate-constpair, a const-lessoneOf, and the good case that must still render a MultiSelect.npm run cipasses locally. (Two pre-existing flakes surfaced under full-suite load and pass in isolation and on re-run — the TUI OAuth step-up poll test and theAppRenderertheme-flip test; neither touches this change.)🤖 Generated with Claude Code
https://claude.ai/code/session_01SKGRe3bER3rxfDYU8K4Dyh