From 57bad4bdf871b7ff4140dfa8ef187bed7626f93a Mon Sep 17 00:00:00 2001 From: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:25:21 -0700 Subject: [PATCH] fix(cron): suppress announce control replies Use the shared suppressed-control-reply detector for cron delivery so NO_REPLY, ANNOUNCE_SKIP, and REPLY_SKIP do not leak to outbound channels, with direct/text delivery coverage. --- .../delivery-dispatch.double-announce.test.ts | 75 ++++++++++++------- src/cron/isolated-agent/delivery-dispatch.ts | 5 +- 2 files changed, 50 insertions(+), 30 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 daa50ceed8c9..85f6b55ef20e 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -1391,36 +1391,55 @@ describe("dispatchCronDelivery — double-announce guard", () => { } }); - it("suppresses NO_REPLY payload in direct delivery so sentinel never leaks to external channels", async () => { - 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); + it.each([SILENT_REPLY_TOKEN, "ANNOUNCE_SKIP", "REPLY_SKIP"])( + "suppresses %s payload in direct delivery so control tokens never leak to external channels", + async (controlToken) => { + const params = makeBaseParams({ synthesizedText: controlToken }); + // 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(); - expectResultFields(state.result, { - status: "ok", - delivered: false, - deliveryAttempted: 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); + // Control tokens must be filtered out before reaching the outbound adapter. + expect(deliverOutboundPayloads).not.toHaveBeenCalled(); + expectResultFields(state.result, { + status: "ok", + delivered: false, + deliveryAttempted: true, + }); + // deliveryAttempted must be true so the heartbeat timer does not fire + // a fallback enqueueSystemEvent with the control-token text. + expect(state.deliveryAttempted).toBe(true); - // Verify timer guard agrees: shouldEnqueueCronMainSummary returns false - expect( - shouldEnqueueCronMainSummary({ - summaryText: "NO_REPLY", - deliveryRequested: true, - delivered: state.result?.delivered, - deliveryAttempted: state.result?.deliveryAttempted, - suppressMainSummary: false, - isCronSystemEvent: () => true, - }), - ).toBe(false); - }); + // Verify timer guard agrees: shouldEnqueueCronMainSummary returns false + expect( + shouldEnqueueCronMainSummary({ + summaryText: controlToken, + deliveryRequested: true, + delivered: state.result?.delivered, + deliveryAttempted: state.result?.deliveryAttempted, + suppressMainSummary: false, + isCronSystemEvent: () => true, + }), + ).toBe(false); + }, + ); + + it.each(["ANNOUNCE_SKIP", "REPLY_SKIP"])( + "suppresses %s payload in text delivery so control tokens never leak to external channels", + async (controlToken) => { + const params = makeBaseParams({ synthesizedText: controlToken }); + const state = await dispatchCronDelivery(params); + + expect(deliverOutboundPayloads).not.toHaveBeenCalled(); + expectResultFields(state.result, { + status: "ok", + delivered: false, + deliveryAttempted: true, + }); + expect(state.deliveryAttempted).toBe(true); + }, + ); it("delivers explicit targets with direct text through the outbound adapter", async () => { const params = makeBaseParams({ synthesizedText: "hello from cron" }); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index f35448308707..8141ef89d734 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -20,6 +20,7 @@ import { import { resolveMirroredTranscriptText } from "../../config/sessions/transcript-mirror.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { TtsAutoMode } from "../../config/types.tts.js"; +import { isSuppressedControlReplyText } from "../../gateway/control-reply-text.js"; import { sleepWithAbort } from "../../infra/backoff.js"; import { formatErrorMessage } from "../../infra/errors.js"; import type { @@ -63,7 +64,7 @@ function normalizeSilentReplyText(text: string | undefined): NormalizedSilentRep if (!text) { return { text, strippedTrailingSilentToken: false }; } - if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) { + if (isSuppressedControlReplyText(text)) { return { text: undefined, strippedTrailingSilentToken: false }; } @@ -81,7 +82,7 @@ function normalizeSilentReplyText(text: string | undefined): NormalizedSilentRep next = stripped; } - if (!next.trim() || isSilentReplyText(next, SILENT_REPLY_TOKEN)) { + if (!next.trim() || isSuppressedControlReplyText(next)) { return { text: undefined, strippedTrailingSilentToken }; } return { text: next, strippedTrailingSilentToken };