fix: gate submission on unsendable field text, and on required tool args

A field that cannot turn its text into a value reports `undefined` — which is
exactly what an empty field reports. That made the two indistinguishable to
every caller of `SchemaForm`, so invalid text in an optional field was
submittable and the argument simply arrived absent (#2020).

`SchemaForm` now tracks per-field draft validity and reports it through
`onValidityChange`, covering both fields that hold text the form cannot send:
the JSON editor (unparseable text) and the number input (a magnitude JS cannot
hold exactly, which is dropped rather than silently rounded). The number field
also states it on itself, as the JSON editor already did. A nested object form
reports through the same channel, and both the field-level and form-level
effects report "valid" on cleanup so a draft cannot outlive the field that held
it — a remount under a new `resetKey`, or an unmounted panel.

`ToolDetailPanel` gated Execute on `isExecuting` alone, so it also had no
required-argument check at all — unlike the App and elicitation panels. It now
uses `hasMissingRequiredFields` like they do. `InlineElicitationRequest` had
neither gate and gets both.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 21:09:51 -04:00
commit 6002c2ce33

View file

@ -218,7 +218,13 @@ describe("ToolDetailPanel", () => {
});
it("renders the Execute Tool button enabled when not executing", () => {
renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />);
renderWithMantine(
<ToolDetailPanel
{...baseProps}
tool={simpleTool}
formValues={{ message: "hi" }}
/>,
);
const button = screen.getByRole("button", { name: "Execute Tool" });
expect(button).toBeInTheDocument();
expect(button).not.toBeDisabled();
@ -231,6 +237,7 @@ describe("ToolDetailPanel", () => {
<ToolDetailPanel
{...baseProps}
tool={simpleTool}
formValues={{ message: "hi" }}
onExecute={onExecute}
/>,
);
@ -502,4 +509,61 @@ describe("ToolDetailPanel", () => {
).not.toBeInTheDocument();
});
});
// #2020: Execute used to be gated on `isExecuting` alone — this panel never
// called `hasMissingRequiredFields`, unlike the App and elicitation panels —
// so arguments that could not be sent were executed anyway and simply arrived
// absent at the server.
describe("submit gating (#2020)", () => {
const optionalJsonTool: Tool = {
name: "record_shipment",
inputSchema: {
type: "object",
// An array of no declared item type renders through the JSON editor,
// which is where a draft can fail to parse.
properties: { payload: { type: "array", title: "Payload" } },
},
};
it("disables Execute while a required argument has no value", () => {
renderWithMantine(
<ToolDetailPanel {...baseProps} tool={simpleTool} formValues={{}} />,
);
expect(
screen.getByRole("button", { name: "Execute Tool" }),
).toBeDisabled();
});
it("enables Execute once the required argument is filled", () => {
renderWithMantine(
<ToolDetailPanel
{...baseProps}
tool={simpleTool}
formValues={{ message: "hi" }}
/>,
);
expect(
screen.getByRole("button", { name: "Execute Tool" }),
).not.toBeDisabled();
});
it("disables Execute while an optional field holds text it cannot send", async () => {
const user = userEvent.setup();
renderWithMantine(
<ToolDetailPanel {...baseProps} tool={optionalJsonTool} />,
);
const execute = screen.getByRole("button", { name: "Execute Tool" });
expect(execute).not.toBeDisabled();
// The draft never reaches `formValues` — the field reports `undefined`
// for text it cannot parse — so only the form's validity channel makes
// this visible to the gate.
const jsonInput = screen.getByLabelText(/Payload/);
await user.type(jsonInput, "x");
expect(execute).toBeDisabled();
await user.clear(jsonInput);
expect(execute).not.toBeDisabled();
});
});
});