fix(cron): suppress trailing NO_REPLY in announce delivery path [AI-assisted] (#65004)

Merged via squash.

Prepared head SHA: b7f1996d60
Co-authored-by: neo1027144-creator <267440006+neo1027144-creator@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
neo1027144
2026-04-16 00:31:35 +08:00
committed by GitHub
parent 32222812ea
commit ee6b7daca3
3 changed files with 70 additions and 4 deletions

View File

@@ -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

View File

@@ -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<string, unknown>).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 }),
);
});
});

View File

@@ -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",