diff --git a/CHANGELOG.md b/CHANGELOG.md index d5b39aa830a..1f69192e774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Docker/build: verify `@matrix-org/matrix-sdk-crypto-nodejs` native bindings with `find` under `node_modules` instead of a hardcoded `.pnpm/...` path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) thanks @ly85206559. - Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring `channels.matrix.password`, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792. +- Cron/announce delivery: suppress mixed-content isolated cron announce replies that end with `NO_REPLY` so trailing silent sentinels no longer leak summary text to the target channel. (#65004) thanks @neo1027144-creator. ## 2026.4.15-beta.1 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 15b1a793a95..413eacb957c 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -940,4 +940,50 @@ describe("dispatchCronDelivery — double-announce guard", () => { timeoutMs: 10_000, }); }); + + it("suppresses trailing NO_REPLY after summary text in direct delivery (#64976)", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + + const params = makeBaseParams({ + synthesizedText: "All 3 items already processed.\n\nNO_REPLY", + }); + (params as Record).deliveryPayloadHasStructuredContent = true; + const state = await dispatchCronDelivery(params); + + expect(deliverOutboundPayloads).not.toHaveBeenCalled(); + expect(state.result).toEqual( + expect.objectContaining({ status: "ok", delivered: false, deliveryAttempted: true }), + ); + }); + + it("suppresses trailing NO_REPLY after summary text in text delivery (#64976)", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + + const params = makeBaseParams({ + synthesizedText: "Nothing actionable found today.\n\nNO_REPLY", + }); + const state = await dispatchCronDelivery(params); + + expect(deliverOutboundPayloads).not.toHaveBeenCalled(); + expect(state.result).toEqual( + expect.objectContaining({ status: "ok", delivered: false, deliveryAttempted: true }), + ); + }); + + it("suppresses mixed-case trailing No_Reply after summary text (#64976)", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + + const params = makeBaseParams({ + synthesizedText: "All done, nothing to report.\n\nNo_Reply", + }); + const state = await dispatchCronDelivery(params); + + expect(deliverOutboundPayloads).not.toHaveBeenCalled(); + expect(state.result).toEqual( + expect.objectContaining({ status: "ok", delivered: false, deliveryAttempted: true }), + ); + }); }); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index 1ec471ff009..f7e081bc895 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -1,5 +1,5 @@ import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; -import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; +import { isSilentReplyText, stripSilentToken, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import type { CliDeps } from "../../cli/outbound-send-deps.js"; import { resolveAgentMainSessionKey, @@ -463,9 +463,20 @@ export async function dispatchCronDelivery( ? [{ text: synthesizedText }] : []; // Suppress NO_REPLY sentinel so it never leaks to external channels. - const payloadsForDelivery = rawPayloads.filter( - (p) => !isSilentReplyText(p.text, SILENT_REPLY_TOKEN), - ); + // Also suppress payloads where the agent appended a trailing NO_REPLY + // after other text (e.g. "summary...\n\nNO_REPLY") — the token signals + // "do not deliver" regardless of preceding content. + const payloadsForDelivery = rawPayloads.filter((p) => { + const text = p.text ?? ""; + if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) { + return false; + } + // Case-insensitive trailing check: uppercase before stripping since + // stripSilentToken's regex is case-sensitive. + const upper = text.toUpperCase(); + const stripped = stripSilentToken(upper, SILENT_REPLY_TOKEN); + return stripped === upper.trim(); + }); if (payloadsForDelivery.length === 0) { return await finishSilentReplyDelivery(); } @@ -689,9 +700,17 @@ export async function dispatchCronDelivery( ...params.telemetry, }); } + // Suppress delivery when synthesizedText is (or ends with) NO_REPLY. + // isSilentReplyText handles case-insensitive exact matches (e.g. "No_Reply"); + // stripSilentToken catches trailing tokens after other text. if (isSilentReplyText(synthesizedText, SILENT_REPLY_TOKEN)) { return await finishSilentReplyDelivery(); } + const upperSynthesized = synthesizedText.toUpperCase(); + const strippedSynthesized = stripSilentToken(upperSynthesized, SILENT_REPLY_TOKEN); + if (strippedSynthesized !== upperSynthesized.trim()) { + return await finishSilentReplyDelivery(); + } if (params.isAborted()) { return params.withRunSession({ status: "error",