From bfc7f0d468d22c0bc9dfb2e1a35d0002fc24e851 Mon Sep 17 00:00:00 2001 From: openperf <16864032@qq.com> Date: Sat, 14 Mar 2026 12:26:19 +0800 Subject: [PATCH 1/3] fix(cron): suppress NO_REPLY sentinel in direct delivery path --- .../delivery-dispatch.double-announce.test.ts | 28 +++++++++++++++++++ src/cron/isolated-agent/delivery-dispatch.ts | 8 ++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index b245b4b9c94..39ecd7d7560 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -413,4 +413,32 @@ describe("dispatchCronDelivery — double-announce guard", () => { vi.unstubAllEnvs(); } }); + + it("suppresses NO_REPLY payload in direct delivery so sentinel never leaks to external channels", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + + const params = makeBaseParams({ synthesizedText: "NO_REPLY" }); + // Force the useDirectDelivery path (structured content) to exercise + // deliverViaDirect without going through finalizeTextDelivery. + (params as Record).deliveryPayloadHasStructuredContent = true; + const state = await dispatchCronDelivery(params); + + // NO_REPLY must be filtered out before reaching the outbound adapter. + expect(deliverOutboundPayloads).not.toHaveBeenCalled(); + // No delivery was sent, so delivered stays false. + expect(state.delivered).toBe(false); + }); + + it("suppresses NO_REPLY payload with surrounding whitespace", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + + const params = makeBaseParams({ synthesizedText: " NO_REPLY " }); + (params as Record).deliveryPayloadHasStructuredContent = true; + const state = await dispatchCronDelivery(params); + + expect(deliverOutboundPayloads).not.toHaveBeenCalled(); + expect(state.delivered).toBe(false); + }); }); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index 6ddddf20669..e80ce5d424d 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"; @@ -314,12 +314,16 @@ export async function dispatchCronDelivery( delivery, }); try { - const payloadsForDelivery = + const rawPayloads = deliveryPayloads.length > 0 ? deliveryPayloads : synthesizedText ? [{ text: synthesizedText }] : []; + // Suppress NO_REPLY sentinel so it never leaks to external channels. + const payloadsForDelivery = rawPayloads.filter( + (p) => !isSilentReplyText(p.text, SILENT_REPLY_TOKEN), + ); if (payloadsForDelivery.length === 0) { return null; } From d4d7af3400614a38509ea4bfff7a3b99250c1436 Mon Sep 17 00:00:00 2001 From: openperf <16864032@qq.com> Date: Sat, 14 Mar 2026 12:44:26 +0800 Subject: [PATCH 2/3] fix: set deliveryAttempted on filtered NO_REPLY to prevent timer fallback --- .../delivery-dispatch.double-announce.test.ts | 27 +++++++++++++++++++ src/cron/isolated-agent/delivery-dispatch.ts | 3 +++ 2 files changed, 30 insertions(+) diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index 39ecd7d7560..3b93dc67964 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -428,6 +428,21 @@ describe("dispatchCronDelivery — double-announce guard", () => { expect(deliverOutboundPayloads).not.toHaveBeenCalled(); // No delivery was sent, so delivered stays false. expect(state.delivered).toBe(false); + // deliveryAttempted must be true so the heartbeat timer does not fire + // a fallback enqueueSystemEvent with the NO_REPLY sentinel text. + expect(state.deliveryAttempted).toBe(true); + + // Verify timer guard agrees: shouldEnqueueCronMainSummary returns false + expect( + shouldEnqueueCronMainSummary({ + summaryText: "NO_REPLY", + deliveryRequested: true, + delivered: state.delivered, + deliveryAttempted: state.deliveryAttempted, + suppressMainSummary: false, + isCronSystemEvent: () => true, + }), + ).toBe(false); }); it("suppresses NO_REPLY payload with surrounding whitespace", async () => { @@ -440,5 +455,17 @@ describe("dispatchCronDelivery — double-announce guard", () => { expect(deliverOutboundPayloads).not.toHaveBeenCalled(); expect(state.delivered).toBe(false); + expect(state.deliveryAttempted).toBe(true); + + expect( + shouldEnqueueCronMainSummary({ + summaryText: " NO_REPLY ", + deliveryRequested: true, + delivered: state.delivered, + deliveryAttempted: state.deliveryAttempted, + suppressMainSummary: false, + isCronSystemEvent: () => true, + }), + ).toBe(false); }); }); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index e80ce5d424d..fad44594333 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -325,6 +325,9 @@ export async function dispatchCronDelivery( (p) => !isSilentReplyText(p.text, SILENT_REPLY_TOKEN), ); if (payloadsForDelivery.length === 0) { + // Mark attempted so the heartbeat timer does not fire a fallback + // enqueueSystemEvent with the NO_REPLY sentinel text. + deliveryAttempted = true; return null; } if (params.isAborted()) { From 8151abb0ad84aef44b443e32ab4b68449f2d6548 Mon Sep 17 00:00:00 2001 From: openperf <16864032@qq.com> Date: Sat, 14 Mar 2026 13:13:47 +0800 Subject: [PATCH 3/3] fix: mark silent NO_REPLY direct deliveries as delivered --- .../delivery-dispatch.double-announce.test.ts | 6 +++--- src/cron/isolated-agent/delivery-dispatch.ts | 13 ++++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index 3b93dc67964..f0df85b6fe6 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -426,8 +426,8 @@ describe("dispatchCronDelivery — double-announce guard", () => { // NO_REPLY must be filtered out before reaching the outbound adapter. expect(deliverOutboundPayloads).not.toHaveBeenCalled(); - // No delivery was sent, so delivered stays false. - expect(state.delivered).toBe(false); + // Mark as silently delivered so the job is persisted as successful. + expect(state.delivered).toBe(true); // deliveryAttempted must be true so the heartbeat timer does not fire // a fallback enqueueSystemEvent with the NO_REPLY sentinel text. expect(state.deliveryAttempted).toBe(true); @@ -454,7 +454,7 @@ describe("dispatchCronDelivery — double-announce guard", () => { const state = await dispatchCronDelivery(params); expect(deliverOutboundPayloads).not.toHaveBeenCalled(); - expect(state.delivered).toBe(false); + expect(state.delivered).toBe(true); expect(state.deliveryAttempted).toBe(true); expect( diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index fad44594333..60fc76c0f5d 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -325,10 +325,17 @@ export async function dispatchCronDelivery( (p) => !isSilentReplyText(p.text, SILENT_REPLY_TOKEN), ); if (payloadsForDelivery.length === 0) { - // Mark attempted so the heartbeat timer does not fire a fallback - // enqueueSystemEvent with the NO_REPLY sentinel text. + // Mark as silently delivered so the heartbeat timer does not fire a fallback + // and the job is persisted as successfully delivered. deliveryAttempted = true; - return null; + delivered = true; + return params.withRunSession({ + status: "ok", + summary, + outputText, + delivered: true, + ...params.telemetry, + }); } if (params.isAborted()) { return params.withRunSession({