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; }