diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index 8b7ffe4a3e6..ec77e6a72d5 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -195,6 +195,42 @@ describe("normalizeCronJobCreate", () => { expectAnnounceDeliveryTarget(delivery, { channel: "telegram", to: "7200373102" }); }); + it("normalizes whitespace-only payload text to empty strings so validation rejects it", () => { + const agentTurn = normalizeCronJobCreate({ + name: "blank agent turn", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { + kind: "agentTurn", + message: " ", + }, + }) as unknown as Record; + expect(agentTurn.payload).toEqual({ kind: "agentTurn", message: "" }); + expect(validateCronAddParams(agentTurn)).toBe(false); + + const systemEvent = normalizeCronJobCreate({ + name: "blank system event", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "now", + payload: { + kind: "systemEvent", + text: " ", + }, + }) as unknown as Record; + expect(systemEvent.payload).toEqual({ kind: "systemEvent", text: "" }); + expect(validateCronAddParams(systemEvent)).toBe(false); + + const update = normalizeCronJobPatch({ + payload: { kind: "agentTurn", message: " " }, + }) as unknown as Record; + expect(update.payload).toEqual({ kind: "agentTurn", message: "" }); + expect(validateCronUpdateParams({ id: "job-1", patch: update })).toBe(false); + }); + it("normalizes delivery accountId and strips blanks", () => { const normalized = normalizeIsolatedAgentTurnCreateJob({ name: "delivery account", diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index c68289b1974..fdfbf738004 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -128,12 +128,16 @@ function coercePayload(payload: UnknownRecord) { const trimmed = normalizeOptionalString(next.message) ?? ""; if (trimmed) { next.message = trimmed; + } else { + next.message = ""; } } if (typeof next.text === "string") { const trimmed = normalizeOptionalString(next.text) ?? ""; if (trimmed) { next.text = trimmed; + } else { + next.text = ""; } } if ("model" in next) { diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index cb70e7de6b5..c2205de4c40 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -404,6 +404,28 @@ describe("cron method validation", () => { expectResponseError(respond, { code: "INVALID_REQUEST" }); }); + it("rejects whitespace-only cron payloads before calling add", async () => { + const agentTurn = await invokeCronAdd( + agentTurnCronParams({ + name: "blank agent turn", + payload: { kind: "agentTurn", message: " " }, + }), + ); + expect(agentTurn.context.cron.add).not.toHaveBeenCalled(); + expectResponseError(agentTurn.respond, { code: "INVALID_REQUEST", messageIncludes: "message" }); + + const systemEvent = await invokeCronAdd({ + name: "blank system event", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: " " }, + }); + expect(systemEvent.context.cron.add).not.toHaveBeenCalled(); + expectResponseError(systemEvent.respond, { code: "INVALID_REQUEST", messageIncludes: "text" }); + }); + it("rejects ambiguous announce delivery on add when multiple channels are configured", async () => { setRuntimeConfig(telegramSlackConfig({ includeMainSession: true }));