diff --git a/CHANGELOG.md b/CHANGELOG.md
index 626e5acf10..a9e7e0fa9f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
- Agents: strip reasoning tags and downgraded tool markers from messaging tool and streaming output to prevent leakage. (#11053, #13453) Thanks @liebertar, @meaadore1221-afk, @gumadeiras.
- Browser: prevent stuck `act:evaluate` from wedging the browser tool, and make cancellation stop waiting promptly. (#13498) Thanks @onutc.
- Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.
+- Web UI: coerce Form Editor values to schema types before `config.set` and `config.apply`, preventing numeric and boolean fields from being serialized as strings. (#13468) Thanks @mcaxtr.
- Tools/web_search: include provider-specific settings in the web search cache key, and pass `inlineCitations` for Grok. (#12419) Thanks @tmchow.
- Tools/web_search: fix Grok response parsing for xAI Responses API output blocks. (#13049) Thanks @ereid7.
- Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey.
diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts
index 342a1e58e6..46948777a0 100644
--- a/ui/src/ui/controllers/config.test.ts
+++ b/ui/src/ui/controllers/config.test.ts
@@ -3,6 +3,7 @@ import {
applyConfigSnapshot,
applyConfig,
runUpdate,
+ saveConfig,
updateConfigFormValue,
type ConfigState,
} from "./config.ts";
@@ -157,6 +158,124 @@ describe("applyConfig", () => {
sessionKey: "agent:main:whatsapp:dm:+15555550123",
});
});
+
+ it("coerces schema-typed values before config.apply in form mode", async () => {
+ const request = vi.fn().mockImplementation(async (method: string) => {
+ if (method === "config.get") {
+ return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
+ }
+ return {};
+ });
+ const state = createState();
+ state.connected = true;
+ state.client = { request } as unknown as ConfigState["client"];
+ state.applySessionKey = "agent:main:web:dm:test";
+ state.configFormMode = "form";
+ state.configForm = {
+ gateway: { port: "18789", debug: "true" },
+ };
+ state.configSchema = {
+ type: "object",
+ properties: {
+ gateway: {
+ type: "object",
+ properties: {
+ port: { type: "number" },
+ debug: { type: "boolean" },
+ },
+ },
+ },
+ };
+ state.configSnapshot = { hash: "hash-apply-1" };
+
+ await applyConfig(state);
+
+ expect(request.mock.calls[0]?.[0]).toBe("config.apply");
+ const params = request.mock.calls[0]?.[1] as {
+ raw: string;
+ baseHash: string;
+ sessionKey: string;
+ };
+ const parsed = JSON.parse(params.raw) as {
+ gateway: { port: unknown; debug: unknown };
+ };
+ expect(typeof parsed.gateway.port).toBe("number");
+ expect(parsed.gateway.port).toBe(18789);
+ expect(parsed.gateway.debug).toBe(true);
+ expect(params.baseHash).toBe("hash-apply-1");
+ expect(params.sessionKey).toBe("agent:main:web:dm:test");
+ });
+});
+
+describe("saveConfig", () => {
+ it("coerces schema-typed values before config.set in form mode", async () => {
+ const request = vi.fn().mockImplementation(async (method: string) => {
+ if (method === "config.get") {
+ return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
+ }
+ return {};
+ });
+ const state = createState();
+ state.connected = true;
+ state.client = { request } as unknown as ConfigState["client"];
+ state.configFormMode = "form";
+ state.configForm = {
+ gateway: { port: "18789", enabled: "false" },
+ };
+ state.configSchema = {
+ type: "object",
+ properties: {
+ gateway: {
+ type: "object",
+ properties: {
+ port: { type: "number" },
+ enabled: { type: "boolean" },
+ },
+ },
+ },
+ };
+ state.configSnapshot = { hash: "hash-save-1" };
+
+ await saveConfig(state);
+
+ expect(request.mock.calls[0]?.[0]).toBe("config.set");
+ const params = request.mock.calls[0]?.[1] as { raw: string; baseHash: string };
+ const parsed = JSON.parse(params.raw) as {
+ gateway: { port: unknown; enabled: unknown };
+ };
+ expect(typeof parsed.gateway.port).toBe("number");
+ expect(parsed.gateway.port).toBe(18789);
+ expect(parsed.gateway.enabled).toBe(false);
+ expect(params.baseHash).toBe("hash-save-1");
+ });
+
+ it("skips coercion when schema is not an object", async () => {
+ const request = vi.fn().mockImplementation(async (method: string) => {
+ if (method === "config.get") {
+ return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
+ }
+ return {};
+ });
+ const state = createState();
+ state.connected = true;
+ state.client = { request } as unknown as ConfigState["client"];
+ state.configFormMode = "form";
+ state.configForm = {
+ gateway: { port: "18789" },
+ };
+ state.configSchema = "invalid-schema";
+ state.configSnapshot = { hash: "hash-save-2" };
+
+ await saveConfig(state);
+
+ expect(request.mock.calls[0]?.[0]).toBe("config.set");
+ const params = request.mock.calls[0]?.[1] as { raw: string; baseHash: string };
+ const parsed = JSON.parse(params.raw) as {
+ gateway: { port: unknown };
+ };
+ expect(parsed.gateway.port).toBe("18789");
+ expect(params.baseHash).toBe("hash-save-2");
+ });
});
describe("runUpdate", () => {
diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts
index 93e6746c14..9ca669aa59 100644
--- a/ui/src/ui/controllers/config.ts
+++ b/ui/src/ui/controllers/config.ts
@@ -1,5 +1,7 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../types.ts";
+import type { JsonSchema } from "../views/config-form.shared.ts";
+import { coerceFormValues } from "./config/form-coerce.ts";
import {
cloneConfigObject,
removePathValue,
@@ -99,6 +101,32 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
}
}
+function asJsonSchema(value: unknown): JsonSchema | null {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ return null;
+ }
+ return value as JsonSchema;
+}
+
+/**
+ * Serialize the form state for submission to `config.set` / `config.apply`.
+ *
+ * HTML `` elements produce string `.value` properties, so numeric and
+ * boolean config fields can leak into `configForm` as strings. We coerce
+ * them back to their schema-defined types before JSON serialization so the
+ * gateway's Zod validation always sees correctly typed values.
+ */
+function serializeFormForSubmit(state: ConfigState): string {
+ if (state.configFormMode !== "form" || !state.configForm) {
+ return state.configRaw;
+ }
+ const schema = asJsonSchema(state.configSchema);
+ const form = schema
+ ? (coerceFormValues(state.configForm, schema) as Record)
+ : state.configForm;
+ return serializeConfigForm(form);
+}
+
export async function saveConfig(state: ConfigState) {
if (!state.client || !state.connected) {
return;
@@ -106,10 +134,7 @@ export async function saveConfig(state: ConfigState) {
state.configSaving = true;
state.lastError = null;
try {
- const raw =
- state.configFormMode === "form" && state.configForm
- ? serializeConfigForm(state.configForm)
- : state.configRaw;
+ const raw = serializeFormForSubmit(state);
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.lastError = "Config hash missing; reload and retry.";
@@ -132,10 +157,7 @@ export async function applyConfig(state: ConfigState) {
state.configApplying = true;
state.lastError = null;
try {
- const raw =
- state.configFormMode === "form" && state.configForm
- ? serializeConfigForm(state.configForm)
- : state.configRaw;
+ const raw = serializeFormForSubmit(state);
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.lastError = "Config hash missing; reload and retry.";
diff --git a/ui/src/ui/controllers/config/form-coerce.ts b/ui/src/ui/controllers/config/form-coerce.ts
new file mode 100644
index 0000000000..d5ceab427f
--- /dev/null
+++ b/ui/src/ui/controllers/config/form-coerce.ts
@@ -0,0 +1,160 @@
+import { schemaType, type JsonSchema } from "../../views/config-form.shared.ts";
+
+function coerceNumberString(value: string, integer: boolean): number | undefined | string {
+ const trimmed = value.trim();
+ if (trimmed === "") {
+ return undefined;
+ }
+ const parsed = Number(trimmed);
+ if (!Number.isFinite(parsed)) {
+ return value;
+ }
+ if (integer && !Number.isInteger(parsed)) {
+ return value;
+ }
+ return parsed;
+}
+
+function coerceBooleanString(value: string): boolean | string {
+ const trimmed = value.trim();
+ if (trimmed === "true") {
+ return true;
+ }
+ if (trimmed === "false") {
+ return false;
+ }
+ return value;
+}
+
+/**
+ * Walk a form value tree alongside its JSON Schema and coerce string values
+ * to their schema-defined types (number, boolean).
+ *
+ * HTML `` elements always produce string `.value` properties. Even
+ * though the form rendering code converts values correctly for most paths,
+ * some interactions (map-field repopulation, re-renders, paste, etc.) can
+ * leak raw strings into the config form state. This utility acts as a
+ * safety net before serialization so that `config.set` always receives
+ * correctly typed JSON.
+ */
+export function coerceFormValues(value: unknown, schema: JsonSchema): unknown {
+ if (value === null || value === undefined) {
+ return value;
+ }
+
+ if (schema.allOf && schema.allOf.length > 0) {
+ let next: unknown = value;
+ for (const segment of schema.allOf) {
+ next = coerceFormValues(next, segment);
+ }
+ return next;
+ }
+
+ const type = schemaType(schema);
+
+ // Handle anyOf/oneOf — try to match the value against a variant
+ if (schema.anyOf || schema.oneOf) {
+ const variants = (schema.anyOf ?? schema.oneOf ?? []).filter(
+ (v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))),
+ );
+
+ if (variants.length === 1) {
+ return coerceFormValues(value, variants[0]);
+ }
+
+ // Try number/boolean coercion for string values
+ if (typeof value === "string") {
+ for (const variant of variants) {
+ const variantType = schemaType(variant);
+ if (variantType === "number" || variantType === "integer") {
+ const coerced = coerceNumberString(value, variantType === "integer");
+ if (coerced === undefined || typeof coerced === "number") {
+ return coerced;
+ }
+ }
+ if (variantType === "boolean") {
+ const coerced = coerceBooleanString(value);
+ if (typeof coerced === "boolean") {
+ return coerced;
+ }
+ }
+ }
+ }
+
+ // For non-string values (objects, arrays), try to recurse into matching variant
+ for (const variant of variants) {
+ const variantType = schemaType(variant);
+ if (variantType === "object" && typeof value === "object" && !Array.isArray(value)) {
+ return coerceFormValues(value, variant);
+ }
+ if (variantType === "array" && Array.isArray(value)) {
+ return coerceFormValues(value, variant);
+ }
+ }
+
+ return value;
+ }
+
+ if (type === "number" || type === "integer") {
+ if (typeof value === "string") {
+ const coerced = coerceNumberString(value, type === "integer");
+ if (coerced === undefined || typeof coerced === "number") {
+ return coerced;
+ }
+ }
+ return value;
+ }
+
+ if (type === "boolean") {
+ if (typeof value === "string") {
+ const coerced = coerceBooleanString(value);
+ if (typeof coerced === "boolean") {
+ return coerced;
+ }
+ }
+ return value;
+ }
+
+ if (type === "object") {
+ if (typeof value !== "object" || Array.isArray(value)) {
+ return value;
+ }
+ const obj = value as Record;
+ const props = schema.properties ?? {};
+ const additional =
+ schema.additionalProperties && typeof schema.additionalProperties === "object"
+ ? schema.additionalProperties
+ : null;
+ const result: Record = {};
+ for (const [key, val] of Object.entries(obj)) {
+ const propSchema = props[key] ?? additional;
+ const coerced = propSchema ? coerceFormValues(val, propSchema) : val;
+ // Omit undefined — "clear field = unset" for optional properties
+ if (coerced !== undefined) {
+ result[key] = coerced;
+ }
+ }
+ return result;
+ }
+
+ if (type === "array") {
+ if (!Array.isArray(value)) {
+ return value;
+ }
+ if (Array.isArray(schema.items)) {
+ // Tuple form: each index has its own schema
+ const tuple = schema.items;
+ return value.map((item, i) => {
+ const s = i < tuple.length ? tuple[i] : undefined;
+ return s ? coerceFormValues(item, s) : item;
+ });
+ }
+ const itemsSchema = schema.items;
+ if (!itemsSchema) {
+ return value;
+ }
+ return value.map((item) => coerceFormValues(item, itemsSchema)).filter((v) => v !== undefined);
+ }
+
+ return value;
+}
diff --git a/ui/src/ui/controllers/config/form-utils.node.test.ts b/ui/src/ui/controllers/config/form-utils.node.test.ts
new file mode 100644
index 0000000000..b1d6954a23
--- /dev/null
+++ b/ui/src/ui/controllers/config/form-utils.node.test.ts
@@ -0,0 +1,471 @@
+import { describe, expect, it } from "vitest";
+import type { JsonSchema } from "../../views/config-form.shared.ts";
+import { coerceFormValues } from "./form-coerce.ts";
+import { cloneConfigObject, serializeConfigForm, setPathValue } from "./form-utils.ts";
+
+/**
+ * Minimal model provider schema matching the Zod-generated JSON Schema for
+ * `models.providers` (see zod-schema.core.ts → ModelDefinitionSchema).
+ */
+const modelDefinitionSchema: JsonSchema = {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ name: { type: "string" },
+ reasoning: { type: "boolean" },
+ contextWindow: { type: "number" },
+ maxTokens: { type: "number" },
+ cost: {
+ type: "object",
+ properties: {
+ input: { type: "number" },
+ output: { type: "number" },
+ cacheRead: { type: "number" },
+ cacheWrite: { type: "number" },
+ },
+ },
+ },
+};
+
+const modelProviderSchema: JsonSchema = {
+ type: "object",
+ properties: {
+ baseUrl: { type: "string" },
+ apiKey: { type: "string" },
+ models: {
+ type: "array",
+ items: modelDefinitionSchema,
+ },
+ },
+};
+
+const modelsConfigSchema: JsonSchema = {
+ type: "object",
+ properties: {
+ providers: {
+ type: "object",
+ additionalProperties: modelProviderSchema,
+ },
+ },
+};
+
+const topLevelSchema: JsonSchema = {
+ type: "object",
+ properties: {
+ gateway: {
+ type: "object",
+ properties: {
+ auth: {
+ type: "object",
+ properties: {
+ token: { type: "string" },
+ },
+ },
+ },
+ },
+ models: modelsConfigSchema,
+ },
+};
+
+function makeConfigWithProvider(): Record {
+ return {
+ gateway: { auth: { token: "test-token" } },
+ models: {
+ providers: {
+ xai: {
+ baseUrl: "https://api.x.ai/v1",
+ models: [
+ {
+ id: "grok-4",
+ name: "Grok 4",
+ contextWindow: 131072,
+ maxTokens: 8192,
+ cost: { input: 0.5, output: 1.0, cacheRead: 0.1, cacheWrite: 0.2 },
+ },
+ ],
+ },
+ },
+ },
+ };
+}
+
+describe("form-utils preserves numeric types", () => {
+ it("serializeConfigForm preserves numbers in JSON output", () => {
+ const form = makeConfigWithProvider();
+ const raw = serializeConfigForm(form);
+ const parsed = JSON.parse(raw);
+ const model = parsed.models.providers.xai.models[0];
+
+ expect(typeof model.maxTokens).toBe("number");
+ expect(model.maxTokens).toBe(8192);
+ expect(typeof model.contextWindow).toBe("number");
+ expect(model.contextWindow).toBe(131072);
+ expect(typeof model.cost.input).toBe("number");
+ expect(model.cost.input).toBe(0.5);
+ });
+
+ it("cloneConfigObject + setPathValue preserves unrelated numeric fields", () => {
+ const form = makeConfigWithProvider();
+ const cloned = cloneConfigObject(form);
+ setPathValue(cloned, ["gateway", "auth", "token"], "new-token");
+
+ const model = cloned.models as Record;
+ const providers = model.providers as Record;
+ const xai = providers.xai as Record;
+ const models = xai.models as Array>;
+ const first = models[0];
+
+ expect(typeof first.maxTokens).toBe("number");
+ expect(first.maxTokens).toBe(8192);
+ expect(typeof first.contextWindow).toBe("number");
+ expect(typeof first.cost).toBe("object");
+ expect(typeof (first.cost as Record).input).toBe("number");
+ });
+});
+
+describe("coerceFormValues", () => {
+ it("coerces string numbers to numbers based on schema", () => {
+ const form = {
+ models: {
+ providers: {
+ xai: {
+ baseUrl: "https://api.x.ai/v1",
+ models: [
+ {
+ id: "grok-4",
+ name: "Grok 4",
+ contextWindow: "131072",
+ maxTokens: "8192",
+ cost: { input: "0.5", output: "1.0", cacheRead: "0.1", cacheWrite: "0.2" },
+ },
+ ],
+ },
+ },
+ },
+ };
+
+ const coerced = coerceFormValues(form, topLevelSchema) as Record;
+ const model = (
+ ((coerced.models as Record).providers as Record)
+ .xai as Record
+ ).models as Array>;
+ const first = model[0];
+
+ expect(typeof first.maxTokens).toBe("number");
+ expect(first.maxTokens).toBe(8192);
+ expect(typeof first.contextWindow).toBe("number");
+ expect(first.contextWindow).toBe(131072);
+ expect(typeof first.cost).toBe("object");
+ const cost = first.cost as Record;
+ expect(typeof cost.input).toBe("number");
+ expect(cost.input).toBe(0.5);
+ expect(typeof cost.output).toBe("number");
+ expect(cost.output).toBe(1);
+ expect(typeof cost.cacheRead).toBe("number");
+ expect(cost.cacheRead).toBe(0.1);
+ expect(typeof cost.cacheWrite).toBe("number");
+ expect(cost.cacheWrite).toBe(0.2);
+ });
+
+ it("preserves already-correct numeric values", () => {
+ const form = makeConfigWithProvider();
+ const coerced = coerceFormValues(form, topLevelSchema) as Record;
+ const model = (
+ ((coerced.models as Record).providers as Record)
+ .xai as Record
+ ).models as Array>;
+ const first = model[0];
+
+ expect(typeof first.maxTokens).toBe("number");
+ expect(first.maxTokens).toBe(8192);
+ });
+
+ it("does not coerce non-numeric strings to numbers", () => {
+ const form = {
+ models: {
+ providers: {
+ xai: {
+ baseUrl: "https://api.x.ai/v1",
+ models: [
+ {
+ id: "grok-4",
+ name: "Grok 4",
+ maxTokens: "not-a-number",
+ },
+ ],
+ },
+ },
+ },
+ };
+
+ const coerced = coerceFormValues(form, topLevelSchema) as Record;
+ const model = (
+ ((coerced.models as Record).providers as Record)
+ .xai as Record
+ ).models as Array>;
+ const first = model[0];
+
+ expect(first.maxTokens).toBe("not-a-number");
+ });
+
+ it("coerces string booleans to booleans based on schema", () => {
+ const form = {
+ models: {
+ providers: {
+ xai: {
+ baseUrl: "https://api.x.ai/v1",
+ models: [
+ {
+ id: "grok-4",
+ name: "Grok 4",
+ reasoning: "true",
+ },
+ ],
+ },
+ },
+ },
+ };
+
+ const coerced = coerceFormValues(form, topLevelSchema) as Record;
+ const model = (
+ ((coerced.models as Record).providers as Record)
+ .xai as Record
+ ).models as Array>;
+ expect(model[0].reasoning).toBe(true);
+ });
+
+ it("handles empty string for number fields as undefined", () => {
+ const form = {
+ models: {
+ providers: {
+ xai: {
+ baseUrl: "https://api.x.ai/v1",
+ models: [
+ {
+ id: "grok-4",
+ name: "Grok 4",
+ maxTokens: "",
+ },
+ ],
+ },
+ },
+ },
+ };
+
+ const coerced = coerceFormValues(form, topLevelSchema) as Record;
+ const model = (
+ ((coerced.models as Record).providers as Record)
+ .xai as Record
+ ).models as Array>;
+ expect(model[0].maxTokens).toBeUndefined();
+ });
+
+ it("passes through null and undefined values untouched", () => {
+ expect(coerceFormValues(null, topLevelSchema)).toBeNull();
+ expect(coerceFormValues(undefined, topLevelSchema)).toBeUndefined();
+ });
+
+ it("handles anyOf schemas with number variant", () => {
+ const schema: JsonSchema = {
+ type: "object",
+ properties: {
+ timeout: {
+ anyOf: [{ type: "number" }, { type: "string" }],
+ },
+ },
+ };
+ const form = { timeout: "30" };
+ const coerced = coerceFormValues(form, schema) as Record;
+ expect(typeof coerced.timeout).toBe("number");
+ expect(coerced.timeout).toBe(30);
+ });
+
+ it("handles integer schema type", () => {
+ const schema: JsonSchema = {
+ type: "object",
+ properties: {
+ count: { type: "integer" },
+ },
+ };
+ const form = { count: "42" };
+ const coerced = coerceFormValues(form, schema) as Record;
+ expect(typeof coerced.count).toBe("number");
+ expect(coerced.count).toBe(42);
+ });
+
+ it("rejects non-integer string for integer schema type", () => {
+ const schema: JsonSchema = {
+ type: "object",
+ properties: {
+ count: { type: "integer" },
+ },
+ };
+ const form = { count: "1.5" };
+ const coerced = coerceFormValues(form, schema) as Record;
+ expect(coerced.count).toBe("1.5");
+ });
+
+ it("does not coerce non-finite numeric strings", () => {
+ const schema: JsonSchema = {
+ type: "object",
+ properties: {
+ timeout: { type: "number" },
+ },
+ };
+ const form = { timeout: "Infinity" };
+ const coerced = coerceFormValues(form, schema) as Record;
+ expect(coerced.timeout).toBe("Infinity");
+ });
+
+ it("supports allOf schema composition", () => {
+ const schema: JsonSchema = {
+ allOf: [
+ {
+ type: "object",
+ properties: {
+ port: { type: "number" },
+ },
+ },
+ {
+ type: "object",
+ properties: {
+ enabled: { type: "boolean" },
+ },
+ },
+ ],
+ };
+ const form = { port: "8080", enabled: "true" };
+ const coerced = coerceFormValues(form, schema) as Record;
+ expect(coerced.port).toBe(8080);
+ expect(coerced.enabled).toBe(true);
+ });
+
+ it("recurses into object inside anyOf (nullable pattern)", () => {
+ const schema: JsonSchema = {
+ type: "object",
+ properties: {
+ settings: {
+ anyOf: [
+ {
+ type: "object",
+ properties: {
+ port: { type: "number" },
+ enabled: { type: "boolean" },
+ },
+ },
+ { type: "null" },
+ ],
+ },
+ },
+ };
+ const form = { settings: { port: "8080", enabled: "true" } };
+ const coerced = coerceFormValues(form, schema) as Record;
+ const settings = coerced.settings as Record;
+ expect(typeof settings.port).toBe("number");
+ expect(settings.port).toBe(8080);
+ expect(settings.enabled).toBe(true);
+ });
+
+ it("recurses into array inside anyOf", () => {
+ const schema: JsonSchema = {
+ type: "object",
+ properties: {
+ items: {
+ anyOf: [
+ {
+ type: "array",
+ items: { type: "object", properties: { count: { type: "number" } } },
+ },
+ { type: "null" },
+ ],
+ },
+ },
+ };
+ const form = { items: [{ count: "5" }] };
+ const coerced = coerceFormValues(form, schema) as Record;
+ const items = coerced.items as Array>;
+ expect(typeof items[0].count).toBe("number");
+ expect(items[0].count).toBe(5);
+ });
+
+ it("handles tuple array schemas by index", () => {
+ const schema: JsonSchema = {
+ type: "object",
+ properties: {
+ pair: {
+ type: "array",
+ items: [{ type: "string" }, { type: "number" }],
+ },
+ },
+ };
+ const form = { pair: ["hello", "42"] };
+ const coerced = coerceFormValues(form, schema) as Record;
+ const pair = coerced.pair as unknown[];
+ expect(pair[0]).toBe("hello");
+ expect(typeof pair[1]).toBe("number");
+ expect(pair[1]).toBe(42);
+ });
+
+ it("preserves tuple indexes when a value is cleared", () => {
+ const schema: JsonSchema = {
+ type: "object",
+ properties: {
+ tuple: {
+ type: "array",
+ items: [{ type: "string" }, { type: "number" }, { type: "string" }],
+ },
+ },
+ };
+ const form = { tuple: ["left", "", "right"] };
+ const coerced = coerceFormValues(form, schema) as Record;
+ const tuple = coerced.tuple as unknown[];
+ expect(tuple).toHaveLength(3);
+ expect(tuple[0]).toBe("left");
+ expect(tuple[1]).toBeUndefined();
+ expect(tuple[2]).toBe("right");
+ });
+
+ it("omits cleared number field from object output", () => {
+ const schema: JsonSchema = {
+ type: "object",
+ properties: {
+ name: { type: "string" },
+ port: { type: "number" },
+ },
+ };
+ const form = { name: "test", port: "" };
+ const coerced = coerceFormValues(form, schema) as Record;
+ expect(coerced.name).toBe("test");
+ expect("port" in coerced).toBe(false);
+ });
+
+ it("filters undefined from array when number item is cleared", () => {
+ const schema: JsonSchema = {
+ type: "object",
+ properties: {
+ values: {
+ type: "array",
+ items: { type: "number" },
+ },
+ },
+ };
+ const form = { values: ["1", "", "3"] };
+ const coerced = coerceFormValues(form, schema) as Record;
+ const values = coerced.values as number[];
+ expect(values).toEqual([1, 3]);
+ });
+
+ it("coerces boolean in anyOf union", () => {
+ const schema: JsonSchema = {
+ type: "object",
+ properties: {
+ flag: {
+ anyOf: [{ type: "boolean" }, { type: "string" }],
+ },
+ },
+ };
+ const form = { flag: "true" };
+ const coerced = coerceFormValues(form, schema) as Record;
+ expect(coerced.flag).toBe(true);
+ });
+});