fix: strip the enum null sentinel, and make the preset test cover its 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>
This commit is contained in:
cliffhall 2026-08-15 18:28:38 -04:00
commit 8356033d58

View file

@ -101,6 +101,30 @@ export type NormalizedNullableUnion<T extends NullableUnionSchema> = Omit<
nullable?: boolean;
};
/**
* Drop a `null` sentinel from an `enum`, since the collapse has already moved
* that fact onto `nullable`.
*
* 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] }`. Leaving
* it there breaks both renderers in different ways — the web dispatcher would
* hand `null` to Mantine as option data, and the TUI's all-strings check would
* reject the whole enum and fall back to a plain text field. Neither is the
* dropdown the schema is asking for.
*
* Returns `undefined` when nothing but `null` was in the list: an enum of only
* `null` permits no value a dropdown could offer, so the field is better served
* by its plain widget than by an empty select.
*/
function stripNullFromEnum(value: unknown): unknown[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const members = value.filter((member) => member !== null);
return members.length > 0 ? members : undefined;
}
/**
* Build the collapsed schema.
*
@ -115,12 +139,17 @@ function collapsed<T extends NullableUnionSchema>(
branch: Record<string, unknown> | undefined,
type: RenderableType,
): NormalizedNullableUnion<T> {
const merged = { ...schema, ...branch };
return {
...schema,
...branch,
...merged,
type,
anyOf: undefined,
nullable: true,
// `enum` is read after the spread so the branch's list wins when it has
// one, and the null sentinel is stripped either way.
...(merged.enum === undefined
? {}
: { enum: stripNullFromEnum(merged.enum) }),
} as NormalizedNullableUnion<T>;
}