mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:20:43 +00:00
fix(cron): suppress trailing NO_REPLY in announce delivery path
The cron announce delivery path only checked isSilentReplyText() which requires the entire payload to be exactly NO_REPLY. When the agent prepends summary text before the token (e.g. 'All 3 items processed. \n\nNO_REPLY'), the exact match fails and the full response leaks to the target channel. Use stripSilentToken() to detect a trailing NO_REPLY token in both deliverViaDirect (structured/direct path) and finalizeTextDelivery (text-only path). When the stripped result differs from the original, the payload carried a trailing NO_REPLY and delivery is suppressed. The existing isSilentReplyText() guard is kept for case-insensitive exact matches (e.g. 'No_Reply') since stripSilentToken uses a case-sensitive regex. Fixes #64976
This commit is contained in:
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user