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:
neo1027144
2026-04-12 04:29:47 +08:00
committed by Josh Lehman
parent 32222812ea
commit 95ffb9402c
2 changed files with 69 additions and 4 deletions

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