diff --git a/assistant/src/__tests__/config-schema.test.ts b/assistant/src/__tests__/config-schema.test.ts index b174f1cc6f1..bf428c588fd 100644 --- a/assistant/src/__tests__/config-schema.test.ts +++ b/assistant/src/__tests__/config-schema.test.ts @@ -2090,9 +2090,8 @@ describe("loadConfig with schema validation", () => { test("recovers from partial heartbeat.activeHours without wiping unrelated fields", () => { // activeHoursStart is explicitly nulled while activeHoursEnd defaults to - // 22 — a mismatch. Without emitting on both paths, delete-and-retry can't - // resolve this (deleting the null side is a no-op) and the whole config - // would be reset to defaults. + // 22 — a mismatch. Single-emit on the null side lets delete-and-retry + // strip activeHoursStart, after which the default (8) restores it. writeConfig({ maxTokens: 4096, heartbeat: { activeHoursStart: null }, @@ -2104,6 +2103,37 @@ describe("loadConfig with schema validation", () => { expect(config.heartbeat.activeHoursEnd).toBe(22); }); + test("partial heartbeat.activeHours preserves the explicit non-null value", () => { + // User sets activeHoursEnd: 20 and nulls activeHoursStart. Single-emit + // on the null side strips activeHoursStart only — default 8 restores + // it, user's explicit 20 survives. Dual-emit would strip both and lose + // the 20. + writeConfig({ + maxTokens: 1234, + heartbeat: { activeHoursStart: null, activeHoursEnd: 20 }, + }); + const config = loadConfig(); + expect(config.maxTokens).toBe(1234); + expect(config.heartbeat.activeHoursStart).toBe(8); + expect(config.heartbeat.activeHoursEnd).toBe(20); + }); + + test("recovers from equal filing.activeHours without wiping unrelated fields", () => { + // activeHoursStart === activeHoursEnd is invalid (empty window). Filing's + // defaults are null/null, so single-emit on one path would strip one side + // and the null default would recreate a mismatch — cascading to a full + // defaults reset that wipes maxTokens. Dual-emit strips both sides so + // both defaults restore to null. + writeConfig({ + maxTokens: 1234, + filing: { activeHoursStart: 5, activeHoursEnd: 5 }, + }); + const config = loadConfig(); + expect(config.maxTokens).toBe(1234); + expect(config.filing.activeHoursStart).toBeNull(); + expect(config.filing.activeHoursEnd).toBeNull(); + }); + test("applies calls defaults when not specified", () => { writeConfig({}); const config = loadConfig(); diff --git a/assistant/src/config/schemas/filing.ts b/assistant/src/config/schemas/filing.ts index b0282a7ec8d..f47516ce022 100644 --- a/assistant/src/config/schemas/filing.ts +++ b/assistant/src/config/schemas/filing.ts @@ -69,11 +69,21 @@ export const FilingConfigSchema = z config.activeHoursEnd != null && config.activeHoursStart === config.activeHoursEnd ) { + // Emit on both fields. Filing's defaults are null/null, so single-emit + // on one side would cascade: delete-and-retry strips one key, the null + // default recreates a new mismatch, and the loader falls back to full + // defaults — wiping unrelated fields like maxTokens. + const message = + "filing.activeHoursStart and filing.activeHoursEnd must not be equal (would create an empty window)"; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["activeHoursStart"], + message, + }); ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["activeHoursEnd"], - message: - "filing.activeHoursStart and filing.activeHoursEnd must not be equal (would create an empty window)", + message, }); } }); diff --git a/assistant/src/config/schemas/heartbeat.ts b/assistant/src/config/schemas/heartbeat.ts index efc26775e52..b246d8fa1e1 100644 --- a/assistant/src/config/schemas/heartbeat.ts +++ b/assistant/src/config/schemas/heartbeat.ts @@ -43,20 +43,15 @@ export const HeartbeatConfigSchema = z const startNull = config.activeHoursStart == null; const endNull = config.activeHoursEnd == null; if (startNull !== endNull) { - // Emit on both fields so validateWithSchema's delete-and-retry repair - // can strip whichever side was set (and no-op the null side), letting - // the config fall back to the schema defaults without a full reset. - const message = - "heartbeat.activeHoursStart and heartbeat.activeHoursEnd must both be set or both be null"; + // Emit on the null side only. Heartbeat's defaults are 8/22, so + // delete-and-retry strips the null key, letting the non-null default + // restore it while preserving the user's explicit value on the other + // side. ctx.addIssue({ code: z.ZodIssueCode.custom, - path: ["activeHoursStart"], - message, - }); - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["activeHoursEnd"], - message, + path: [startNull ? "activeHoursStart" : "activeHoursEnd"], + message: + "heartbeat.activeHoursStart and heartbeat.activeHoursEnd must both be set or both be null", }); return; }