diff --git a/CHANGELOG.md b/CHANGELOG.md index a5a4e5aefd2..1c519affc4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony. - LINE/Voice transcription: classify M4A voice media as `audio/mp4` (not `video/mp4`) by checking the MPEG-4 `ftyp` major brand (`M4A ` / `M4B `), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob. +- Cron/Delivery mode none: send explicit `delivery: { mode: "none" }` from cron editor for both add and update flows so previous announce delivery is actually cleared. Landed from contributor PR #31145 by @byungsker. Thanks @byungsker. - Agents/Thinking fallback: when providers reject unsupported thinking levels without enumerating alternatives, retry with `think=off` to avoid hard failure during model/provider fallback chains. Landed from contributor PR #31002 by @yfge. Thanks @yfge. - Agents/Failover reason classification: avoid false rate-limit classification from incidental `tpm` substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM. - Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct `accountId` instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002. diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/ui/controllers/cron.test.ts index d8e55589360..50bdf5811fc 100644 --- a/ui/src/ui/controllers/cron.test.ts +++ b/ui/src/ui/controllers/cron.test.ts @@ -119,6 +119,91 @@ describe("cron controller", () => { }); }); + it('sends delivery: { mode: "none" } explicitly in cron.add payload', async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "cron.add") { + return { id: "job-none-add" }; + } + if (method === "cron.list") { + return { jobs: [] }; + } + if (method === "cron.status") { + return { enabled: true, jobs: 0, nextWakeAtMs: null }; + } + return {}; + }); + + const state = createState({ + client: { + request, + } as unknown as CronState["client"], + cronForm: { + ...DEFAULT_CRON_FORM, + name: "none delivery job", + scheduleKind: "every", + everyAmount: "1", + everyUnit: "minutes", + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payloadKind: "agentTurn", + payloadText: "run this", + deliveryMode: "none", + }, + }); + + await addCronJob(state); + + const addCall = request.mock.calls.find(([method]) => method === "cron.add"); + expect(addCall).toBeDefined(); + expect((addCall?.[1] as { delivery?: unknown } | undefined)?.delivery).toEqual({ + mode: "none", + }); + }); + + it('sends delivery: { mode: "none" } explicitly in cron.update patch', async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "cron.update") { + return { id: "job-none-update" }; + } + if (method === "cron.list") { + return { jobs: [{ id: "job-none-update" }] }; + } + if (method === "cron.status") { + return { enabled: true, jobs: 1, nextWakeAtMs: null }; + } + return {}; + }); + + const state = createState({ + client: { + request, + } as unknown as CronState["client"], + cronEditingJobId: "job-none-update", + cronForm: { + ...DEFAULT_CRON_FORM, + name: "switch to none", + scheduleKind: "every", + everyAmount: "30", + everyUnit: "minutes", + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payloadKind: "agentTurn", + payloadText: "do work", + deliveryMode: "none", + }, + }); + + await addCronJob(state); + + const updateCall = request.mock.calls.find(([method]) => method === "cron.update"); + expect(updateCall).toBeDefined(); + expect( + (updateCall?.[1] as { patch?: { delivery?: unknown } } | undefined)?.patch?.delivery, + ).toEqual({ + mode: "none", + }); + }); + it("does not submit stale announce delivery when unsupported", async () => { const request = vi.fn(async (method: string, _payload?: unknown) => { if (method === "cron.add") { @@ -159,8 +244,13 @@ describe("cron controller", () => { expect(addCall?.[1]).toMatchObject({ name: "main job", }); - expect((addCall?.[1] as { delivery?: unknown } | undefined)?.delivery).toBeUndefined(); - expect(state.cronForm.deliveryMode).toBe("none"); + // Delivery is explicitly sent as { mode: "none" } to clear the announce delivery on the backend. + // Previously this was sent as undefined, which left announce in place (bug #31075). + expect((addCall?.[1] as { delivery?: unknown } | undefined)?.delivery).toEqual({ + mode: "none", + }); + // After submit, form is reset to defaults (deliveryMode = "announce" from DEFAULT_CRON_FORM). + expect(state.cronForm.deliveryMode).toBe("announce"); }); it("submits cron.update when editing an existing job", async () => { diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 16d84c1933f..79417fbfe04 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -614,20 +614,18 @@ export async function addCronJob(state: CronState) { const payload = buildCronPayload(form); const selectedDeliveryMode = form.deliveryMode; const delivery = - selectedDeliveryMode === "none" - ? state.cronEditingJobId - ? { mode: "none" as const } - : undefined - : selectedDeliveryMode - ? { - mode: selectedDeliveryMode, - channel: - selectedDeliveryMode === "announce" - ? form.deliveryChannel.trim() || "last" - : undefined, - to: form.deliveryTo.trim() || undefined, - bestEffort: form.deliveryBestEffort, - } + selectedDeliveryMode && selectedDeliveryMode !== "none" + ? { + mode: selectedDeliveryMode, + channel: + selectedDeliveryMode === "announce" + ? form.deliveryChannel.trim() || "last" + : undefined, + to: form.deliveryTo.trim() || undefined, + bestEffort: form.deliveryBestEffort, + } + : selectedDeliveryMode === "none" + ? ({ mode: "none" } as const) : undefined; const failureAlert = buildFailureAlert(form); const agentId = form.clearAgent ? null : form.agentId.trim();