diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index d58683aedea..f8bf6d8ecf6 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -212,6 +212,7 @@ Behavior details: - Content: delivery uses the isolated run's outbound payloads (text/media) with normal chunking and channel formatting. - Heartbeat-only responses (`HEARTBEAT_OK` with no real content) are not delivered. +- Exact silent responses (`NO_REPLY`, after trimming) are not delivered. - If the isolated run already sent a message to the same target via the message tool, delivery is skipped to avoid duplicates. - Missing or invalid delivery targets fail the job unless `delivery.bestEffort = true`. diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 6ee25859749..97dd8c01f34 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -18,6 +18,8 @@ Tip: run `openclaw cron --help` for the full command surface. Note: isolated `cron add` jobs default to `--announce` delivery. Use `--no-deliver` to keep output internal. `--deliver` remains as a deprecated alias for `--announce`. +When an announced isolated run replies with exact `NO_REPLY` (after trimming), OpenClaw suppresses +the outbound delivery. Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after-run` to keep them. diff --git a/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts b/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts index 0ee64e789fc..c82ee2015eb 100644 --- a/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts +++ b/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts @@ -61,4 +61,46 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => { }); }); }); + + it('suppresses exact "NO_REPLY" for plain announce delivery', async () => { + await withTempCronHome(async (home) => { + const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); + const deps = createCliDeps(); + mockAgentPayloads([{ text: "NO_REPLY" }]); + + const res = await runTelegramAnnounceTurn({ + home, + storePath, + deps, + delivery: { mode: "announce", channel: "telegram", to: "123" }, + }); + + expect(res.status).toBe("ok"); + expect(res.delivered).toBe(false); + expect(res.deliveryAttempted).toBe(true); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); + }); + }); + + it('suppresses exact "NO_REPLY" for forum-topic announce delivery', async () => { + await withTempCronHome(async (home) => { + const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); + const deps = createCliDeps(); + mockAgentPayloads([{ text: "NO_REPLY" }]); + + const res = await runTelegramAnnounceTurn({ + home, + storePath, + deps, + delivery: { mode: "announce", channel: "telegram", to: "123:topic:42" }, + }); + + expect(res.status).toBe("ok"); + expect(res.delivered).toBe(false); + expect(res.deliveryAttempted).toBe(true); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index 9a5adcc2627..bd03c27743b 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -407,6 +407,24 @@ describe("runCronIsolatedAgentTurn", () => { }); }); + it("skips announce when the agent reply is whitespace-padded NO_REPLY", async () => { + await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => { + mockAgentPayloads([{ text: " NO_REPLY \n" }]); + const res = await runTelegramAnnounceTurn({ + home, + storePath, + deps, + delivery: { mode: "announce", channel: "telegram", to: "123" }, + }); + + expect(res.status).toBe("ok"); + expect(res.delivered).toBe(false); + expect(res.deliveryAttempted).toBe(true); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); + }); + }); + it("fails when structured direct delivery fails and best-effort is disabled", async () => { await expectStructuredTelegramFailure({ payload: { text: "hello from cron", mediaUrl: "https://example.com/img.png" }, diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index eda32740e4a..d4f056e1a12 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -1,5 +1,5 @@ import { countActiveDescendantRuns } from "../../agents/subagent-registry.js"; -import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; +import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -112,6 +112,24 @@ export type DispatchCronDeliveryState = { deliveryPayloads: ReplyPayload[]; }; +function isDirectSilentReplyOnly(payloads: readonly ReplyPayload[]): boolean { + if (payloads.length !== 1) { + return false; + } + const [payload] = payloads; + if (!payload || !isSilentReplyText(payload.text, SILENT_REPLY_TOKEN)) { + return false; + } + return !( + payload.mediaUrl || + (payload.mediaUrls?.length ?? 0) > 0 || + payload.interactive || + payload.btw || + payload.audioAsVoice === true || + Object.keys(payload.channelData ?? {}).length > 0 + ); +} + const TRANSIENT_DIRECT_CRON_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [ /\berrorcode=unavailable\b/i, /\bstatus\s*[:=]\s*"?unavailable\b/i, @@ -340,6 +358,18 @@ export async function dispatchCronDelivery( if (payloadsForDelivery.length === 0) { return null; } + if (isDirectSilentReplyOnly(payloadsForDelivery)) { + deliveryAttempted = true; + delivered = false; + return params.withRunSession({ + status: "ok", + summary, + outputText, + delivered: false, + deliveryAttempted: true, + ...params.telemetry, + }); + } if (params.isAborted()) { return params.withRunSession({ status: "error", @@ -522,7 +552,7 @@ export async function dispatchCronDelivery( hadDescendants && synthesizedText.trim() === initialSynthesizedText && isLikelyInterimCronMessage(initialSynthesizedText) && - initialSynthesizedText.toUpperCase() !== SILENT_REPLY_TOKEN.toUpperCase() + !isSilentReplyText(initialSynthesizedText, SILENT_REPLY_TOKEN) ) { // Descendants existed but no post-orchestration synthesis arrived AND // no descendant fallback reply was available. Suppress stale parent @@ -537,12 +567,13 @@ export async function dispatchCronDelivery( ...params.telemetry, }); } - if (synthesizedText.toUpperCase() === SILENT_REPLY_TOKEN.toUpperCase()) { + if (isSilentReplyText(synthesizedText, SILENT_REPLY_TOKEN)) { return params.withRunSession({ status: "ok", summary, outputText, - delivered: true, + delivered: false, + deliveryAttempted: true, ...params.telemetry, }); }