From 526989df7298903a31d229004719726a12d109b8 Mon Sep 17 00:00:00 2001 From: Minxi Hou Date: Fri, 2 Oct 2026 12:15:11 -0400 Subject: [PATCH] fix(api): accept a per-combo universal handoff setting The combo create and update schemas omitted universal_handoff, so Zod stripped it before the route wrote the combo and an operator could not turn handoff off. The runtime already reads combo.universal_handoff or combo.universalHandoff, falling back to the process default when neither is stored. Accept both keys, and treat a payload that carries only the handoff setting as a real update. The process-wide default stays enabled. Signed-off-by: Minxi Hou --- src/shared/validation/schemas/combo.ts | 23 +++++ .../combo-universal-handoff-15251.test.ts | 94 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 tests/unit/combo-universal-handoff-15251.test.ts diff --git a/src/shared/validation/schemas/combo.ts b/src/shared/validation/schemas/combo.ts index 1b11820c1dd3..d97a42012639 100644 --- a/src/shared/validation/schemas/combo.ts +++ b/src/shared/validation/schemas/combo.ts @@ -319,6 +319,23 @@ export const comboRuntimeConfigSchema = z return config; }); +// Per-combo universal handoff. Runtime reads combo.universal_handoff (and the +// camelCase alias) in phaseComboSetup; an omitted schema field is stripped by +// Zod before the route writes the combo, so enabled:false never persisted +// (#15251). The process-wide default stays enabled:true. +export const universalHandoffSchema = z + .object({ + enabled: z.boolean().optional(), + trigger: z.enum(["always", "on-switch", "on-error"]).optional(), + providerAllowlist: z.array(z.string().trim().min(1).max(200)).max(100).optional(), + maxMessagesForSummary: z.number().int().min(5).max(100).optional(), + handoffModel: z.string().trim().max(300).optional(), + ttlMinutes: z.number().int().min(1).max(10080).optional(), + preserveSystemPrompt: z.boolean().optional(), + relayMode: z.enum(["schema-locked", "standard"]).optional(), + }) + .strict(); + export const comboNameSchema = z .string() .trim() @@ -374,6 +391,8 @@ export const createComboSchema = z system_message: z.string().max(50000).optional(), tool_filter_regex: z.string().max(1000).optional(), context_cache_protection: z.boolean().optional(), + universal_handoff: universalHandoffSchema.optional(), + universalHandoff: universalHandoffSchema.optional(), context_length: z.number().int().min(1000).max(2000000).optional(), // Optional embedding dimensions override for embedding combos. // When set, the value is injected into every upstream embedding request as @@ -450,6 +469,8 @@ export const updateComboSchema = z system_message: z.string().max(50000).optional().nullable(), tool_filter_regex: z.string().max(1000).optional().nullable(), context_cache_protection: z.boolean().optional().nullable(), + universal_handoff: universalHandoffSchema.optional().nullable(), + universalHandoff: universalHandoffSchema.optional().nullable(), context_length: z.number().int().min(1000).max(2000000).optional().nullable(), compressionOverride: comboCompressionOverrideSchema.optional(), dimensions: z @@ -473,6 +494,8 @@ export const updateComboSchema = z value.system_message === undefined && value.tool_filter_regex === undefined && value.context_cache_protection === undefined && + value.universal_handoff === undefined && + value.universalHandoff === undefined && value.context_length === undefined && value.compressionOverride === undefined && value.dimensions === undefined diff --git a/tests/unit/combo-universal-handoff-15251.test.ts b/tests/unit/combo-universal-handoff-15251.test.ts new file mode 100644 index 000000000000..dd9c0953a427 --- /dev/null +++ b/tests/unit/combo-universal-handoff-15251.test.ts @@ -0,0 +1,94 @@ +/** + * #15251 — a combo payload that disables universal handoff must survive the + * combo API schemas. createComboSchema / updateComboSchema omitted the field, + * and Zod's default strip policy dropped it before the route wrote the combo, + * so an operator could not persist enabled: false. + * + * Does not touch DEFAULT_UNIVERSAL_HANDOFF_CONFIG.enabled (still true). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-handoff-15251-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { createComboSchema, updateComboSchema } = + await import("../../src/shared/validation/schemas.ts"); +const core = await import("../../src/lib/db/core.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); + +const disabledHandoff = { + enabled: false, + trigger: "on-switch" as const, + providerAllowlist: [], + maxMessagesForSummary: 30, + handoffModel: "", + ttlMinutes: 300, + preserveSystemPrompt: true, +}; + +async function resetStorage() { + core.resetDbInstance(); + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("createComboSchema keeps a disabled universal_handoff", () => { + const parsed = createComboSchema.parse({ + name: "Handoff Off", + models: [{ provider: "openai", model: "gpt-4.1" }], + universal_handoff: disabledHandoff, + }); + assert.deepEqual(parsed.universal_handoff, disabledHandoff); +}); + +test("updateComboSchema keeps a disabled universal_handoff and counts it as an update", () => { + const parsed = updateComboSchema.parse({ + universal_handoff: disabledHandoff, + }); + assert.deepEqual(parsed.universal_handoff, disabledHandoff); + assert.doesNotThrow(() => updateComboSchema.parse({ universal_handoff: disabledHandoff })); +}); + +test("updateComboSchema accepts camelCase universalHandoff and null to clear", () => { + const parsed = updateComboSchema.parse({ universalHandoff: disabledHandoff }); + assert.deepEqual(parsed.universalHandoff, disabledHandoff); + const cleared = updateComboSchema.parse({ universal_handoff: null }); + assert.equal(cleared.universal_handoff, null); +}); + +test("a disabled universal_handoff round-trips through createCombo and updateCombo", async () => { + const created = await combosDb.createCombo( + createComboSchema.parse({ + name: "Persist Handoff Off", + models: [{ provider: "openai", model: "gpt-4.1" }], + universal_handoff: disabledHandoff, + }) + ); + assert.deepEqual(created.universal_handoff, disabledHandoff); + + const reread = await combosDb.getComboById(created.id as string); + assert.ok(reread); + assert.deepEqual(reread!.universal_handoff, disabledHandoff); + + const updated = await combosDb.updateCombo( + created.id as string, + updateComboSchema.parse({ universal_handoff: { enabled: false } }) + ); + assert.ok(updated); + assert.deepEqual(updated!.universal_handoff, { enabled: false }); +});