diff --git a/CHANGELOG.md b/CHANGELOG.md index c2036917a64..1740725504d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - Gateway/startup: fix spurious SIGUSR1 restart loop on Linux/systemd when plugin auto-enable is the only startup config write; the config hash guard was not captured for that write path, causing chokidar to treat each boot write as an external change and trigger a reload → restart cycle that corrupts manifest.db after repeated cycles. Fixes #67436. (#67557) thanks @openperf - OpenAI Codex/CLI: keep resumed `codex exec resume` runs on the safe non-interactive path without reintroducing the removed dangerous bypass flag by passing the supported `--skip-git-repo-check` resume arg that real Codex CLI requires outside trusted git directories. (#67666) Thanks @plgonzalezrx8. - Codex/app-server: parse Desktop-originated app-server user agents such as `Codex Desktop/0.118.0`, keeping the version gate working when the Codex CLI inherits a multi-word originator. (#64666) Thanks @cyrusaf. +- Cron/announce delivery: keep isolated announce `NO_REPLY` stripping case-insensitive across direct and text delivery, preserve structured media-only sends when a caption strips silent, and derive main-session awareness from the cleaned payloads so silent captions no longer leak stale `NO_REPLY` text. (#65016) Thanks @BKF-Gitty. ## 2026.4.15-beta.1 diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index db308f55657..cce280e6480 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -1,4 +1,10 @@ -import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import { + isSilentReplyText, + SILENT_REPLY_TOKEN, + startsWithSilentToken, + stripLeadingSilentToken, + stripSilentToken, +} from "../auto-reply/tokens.js"; import { defaultRuntime } from "../runtime.js"; import { isCronSessionKey } from "../sessions/session-key-utils.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; @@ -127,6 +133,27 @@ function isWakeContinuationRun(runId: string): boolean { return stripWakeRunSuffixes(trimmed) !== trimmed; } +function stripAndClassifyReply(text: string): string | null { + let result = text; + let didStrip = false; + const hasLeadingSilentToken = startsWithSilentToken(result, SILENT_REPLY_TOKEN); + if (hasLeadingSilentToken) { + result = stripLeadingSilentToken(result, SILENT_REPLY_TOKEN); + didStrip = true; + } + if (hasLeadingSilentToken || result.toLowerCase().includes(SILENT_REPLY_TOKEN.toLowerCase())) { + result = stripSilentToken(result, SILENT_REPLY_TOKEN); + didStrip = true; + } + if ( + didStrip && + (!result.trim() || isSilentReplyText(result, SILENT_REPLY_TOKEN) || isAnnounceSkip(result)) + ) { + return null; + } + return result; +} + async function wakeSubagentRunAfterDescendants(params: { runId: string; childSessionKey: string; @@ -385,10 +412,29 @@ export async function runSubagentAnnounceFlow(params: { if (isAnnounceSkip(reply) || isSilentReplyText(reply, SILENT_REPLY_TOKEN)) { if (fallbackReply && !fallbackIsSilent) { - reply = fallbackReply; + const cleaned = stripAndClassifyReply(fallbackReply); + if (cleaned === null) { + return true; + } + reply = cleaned; } else { return true; } + } else if (reply) { + const cleaned = stripAndClassifyReply(reply); + if (cleaned === null) { + if (fallbackReply && !fallbackIsSilent) { + const cleanedFallback = stripAndClassifyReply(fallbackReply); + if (cleanedFallback === null) { + return true; + } + reply = cleanedFallback; + } else { + return true; + } + } else { + reply = cleaned; + } } } diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index c6a1065d1a1..e8883ff6bab 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -69,7 +69,7 @@ export function normalizeReplyPayload( if (hasLeadingSilentToken) { text = stripLeadingSilentToken(text, silentToken); } - if (hasLeadingSilentToken || text.includes(silentToken)) { + if (hasLeadingSilentToken || text.toLowerCase().includes(silentToken.toLowerCase())) { text = stripSilentToken(text, silentToken); if (!hasContent(text)) { opts.onSkip?.("silent"); diff --git a/src/auto-reply/tokens.ts b/src/auto-reply/tokens.ts index ea6c47dc9ff..3385fde95d8 100644 --- a/src/auto-reply/tokens.ts +++ b/src/auto-reply/tokens.ts @@ -24,7 +24,7 @@ function getSilentTrailingRegex(token: string): RegExp { return cached; } const escaped = escapeRegExp(token); - const regex = new RegExp(`(?:^|\\s+|\\*+)${escaped}\\s*$`); + const regex = new RegExp(`(?:^|\\s+|\\*+)${escaped}\\s*$`, "i"); silentTrailingRegexByToken.set(token, regex); return regex; } 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 413eacb957c..52dc888b592 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -122,7 +122,7 @@ function makeBaseParams(overrides: { runSessionId?: string; sessionTarget?: string; deliveryBestEffort?: boolean; -}) { +}): Parameters[0] { const resolvedDelivery = makeResolvedDelivery(); return { cfg: {} as never, @@ -327,6 +327,32 @@ describe("dispatchCronDelivery — double-announce guard", () => { }); }); + it("skips awareness text when direct delivery strips a silent caption", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + + const params = makeBaseParams({ synthesizedText: undefined }); + params.deliveryPayloadHasStructuredContent = true; + params.deliveryPayloads = [ + { mediaUrl: "https://example.com/image.png", text: "All done\n\nNO_REPLY" }, + ]; + params.outputText = "All done\n\nNO_REPLY"; + params.summary = "All done\n\nNO_REPLY"; + + const state = await dispatchCronDelivery(params); + + expect(state.result).toBeUndefined(); + expect(state.delivered).toBe(true); + expect(state.deliveryAttempted).toBe(true); + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); + expect(deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ mediaUrl: "https://example.com/image.png", text: undefined }], + }), + ); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + }); + it("keeps the cron run successful when awareness queueing throws after delivery", async () => { vi.mocked(countActiveDescendantRuns).mockReturnValue(0); vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); @@ -986,4 +1012,73 @@ describe("dispatchCronDelivery — double-announce guard", () => { expect.objectContaining({ status: "ok", delivered: false, deliveryAttempted: true }), ); }); + + it("delivers substantive text that mentions NO_REPLY in non-trailing content (text delivery)", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + + const params = makeBaseParams({ + synthesizedText: + "The NO_REPLY sentinel tells the agent to skip delivery when nothing changes.", + }); + const state = await dispatchCronDelivery(params); + + expect(state.deliveryAttempted).toBe(true); + expect(state.delivered).toBe(true); + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); + }); + + it("delivers substantive text that mentions NO_REPLY in non-trailing content (direct delivery)", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + + const params = makeBaseParams({ + synthesizedText: + "Reminder: reply NO_REPLY when there is nothing to announce, otherwise send a summary.", + }); + (params as Record).deliveryPayloadHasStructuredContent = true; + const state = await dispatchCronDelivery(params); + + expect(state.deliveryAttempted).toBe(true); + expect(state.delivered).toBe(true); + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); + }); + + it("delivers non-trailing NO_REPLY mention with trailing whitespace", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + + const params = makeBaseParams({ + synthesizedText: "Use NO_REPLY when nothing actionable changed.\n", + }); + const state = await dispatchCronDelivery(params); + + expect(state.deliveryAttempted).toBe(true); + expect(state.delivered).toBe(true); + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); + }); + + it("drops only the payload with trailing NO_REPLY in a multi-payload direct delivery", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + + const params = makeBaseParams({ synthesizedText: undefined }); + params.deliveryPayloads = [ + { text: "Working on it..." }, + { text: "Final weather summary\n\nNO_REPLY" }, + ]; + params.summary = "Working on it..."; + params.outputText = "Working on it..."; + + const state = await dispatchCronDelivery(params); + + expect(state.deliveryAttempted).toBe(true); + expect(state.delivered).toBe(true); + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); + expect(deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ text: "Working on it..." }], + }), + ); + }); }); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index f7e081bc895..fe5e7e8513d 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -1,5 +1,11 @@ import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; -import { isSilentReplyText, stripSilentToken, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; +import { + isSilentReplyText, + SILENT_REPLY_TOKEN, + startsWithSilentToken, + stripLeadingSilentToken, + stripSilentToken, +} from "../../auto-reply/tokens.js"; import type { CliDeps } from "../../cli/outbound-send-deps.js"; import { resolveAgentMainSessionKey, @@ -10,6 +16,7 @@ import { sleepWithAbort } from "../../infra/backoff.js"; import { formatErrorMessage } from "../../infra/errors.js"; import type { OutboundDeliveryResult } from "../../infra/outbound/deliver.js"; import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; +import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -18,7 +25,7 @@ import { import { hasScheduledNextRunAtMs } from "../service/jobs.js"; import type { CronJob, CronRunTelemetry } from "../types.js"; import type { DeliveryTargetResolution } from "./delivery-target.js"; -import { pickSummaryFromOutput } from "./helpers.js"; +import { pickLastNonEmptyTextFromPayloads, pickSummaryFromOutput } from "./helpers.js"; import type { RunCronAgentTurnResult } from "./run.types.js"; import { expectsSubagentFollowup, isLikelyInterimCronMessage } from "./subagent-followup-hints.js"; @@ -27,6 +34,39 @@ function normalizeDeliveryTarget(channel: string, to: string): string { return normalizeTargetForProvider(channel, toTrimmed) ?? toTrimmed; } +type NormalizedSilentReplyText = { + text: string | undefined; + strippedTrailingSilentToken: boolean; +}; + +function normalizeSilentReplyText(text: string | undefined): NormalizedSilentReplyText { + if (!text) { + return { text, strippedTrailingSilentToken: false }; + } + if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) { + return { text: undefined, strippedTrailingSilentToken: false }; + } + + let next = text; + const hasLeadingSilentToken = startsWithSilentToken(next, SILENT_REPLY_TOKEN); + if (hasLeadingSilentToken) { + next = stripLeadingSilentToken(next, SILENT_REPLY_TOKEN); + } + + let strippedTrailingSilentToken = false; + if (hasLeadingSilentToken || next.toLowerCase().includes(SILENT_REPLY_TOKEN.toLowerCase())) { + const trimmedBefore = next.trim(); + const stripped = stripSilentToken(next, SILENT_REPLY_TOKEN); + strippedTrailingSilentToken = stripped !== trimmedBefore; + next = stripped; + } + + if (!next.trim() || isSilentReplyText(next, SILENT_REPLY_TOKEN)) { + return { text: undefined, strippedTrailingSilentToken }; + } + return { text: next, strippedTrailingSilentToken }; +} + export function matchesMessagingToolDeliveryTarget( target: { provider?: string; to?: string; accountId?: string }, delivery: { channel?: string; to?: string; accountId?: string }, @@ -293,9 +333,12 @@ async function queueCronAwarenessSystemEvent(params: { deliveryIdempotencyKey: string; outputText?: string; synthesizedText?: string; + deliveryPayloads?: ReplyPayload[]; }): Promise { - const text = - normalizeOptionalString(params.outputText) ?? normalizeOptionalString(params.synthesizedText); + const text = params.deliveryPayloads + ? pickLastNonEmptyTextFromPayloads(params.deliveryPayloads) + : (normalizeOptionalString(params.outputText) ?? + normalizeOptionalString(params.synthesizedText)); if (!text) { return; } @@ -462,21 +505,18 @@ export async function dispatchCronDelivery( : synthesizedText ? [{ text: synthesizedText }] : []; - // Suppress NO_REPLY sentinel so it never leaks to external channels. - // 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(); - }); + const payloadsForDelivery = rawPayloads + .map((p) => { + if (!p.text) { + return p; + } + const normalized = normalizeSilentReplyText(p.text); + return { + ...p, + text: normalized.strippedTrailingSilentToken ? undefined : normalized.text, + }; + }) + .filter((p) => hasReplyPayloadContent(p, { trimText: true })); if (payloadsForDelivery.length === 0) { return await finishSilentReplyDelivery(); } @@ -583,6 +623,7 @@ export async function dispatchCronDelivery( deliveryIdempotencyKey, outputText, synthesizedText, + deliveryPayloads: payloadsForDelivery, }); } if (delivered) { @@ -700,17 +741,15 @@ 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()) { + const normalizedSynthesizedText = normalizeSilentReplyText(synthesizedText); + if ( + normalizedSynthesizedText.text === undefined || + normalizedSynthesizedText.strippedTrailingSilentToken + ) { return await finishSilentReplyDelivery(); } + synthesizedText = normalizedSynthesizedText.text; + outputText = synthesizedText; if (params.isAborted()) { return params.withRunSession({ status: "error",