diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa98109a..ae5894203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Agents/Failover reason classification: avoid false rate-limit classification from incidental `tpm` substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM. - Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct `accountId` instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002. - Tools/Edit workspace boundary errors: preserve the real `Path escapes workspace root` failure path instead of surfacing a misleading access/file-not-found error when editing outside workspace roots. Landed from contributor PR #31015 by @haosenwang1018. Thanks @haosenwang1018. +- Auto-reply/NO_REPLY: strip `NO_REPLY` token from mixed-content messages instead of leaking raw control text to end users. Landed from contributor PR #31080 by @scoootscooob. Thanks @scoootscooob. - Sandbox/mkdirp boundary checks: allow directory-safe boundary validation for existing in-boundary subdirectories, preventing false `cannot create directories` failures in sandbox write mode. (#30610) Thanks @glitch418x. - Android/Voice screen TTS: stream assistant speech via ElevenLabs WebSocket in Talk Mode, stop cleanly on speaker mute/barge-in, and ignore stale out-of-order stream events. (#29521) Thanks @gregmousseau. - Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin. diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index 0436b1a1d..9aafb66bd 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -1,6 +1,11 @@ import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers.js"; import { stripHeartbeatToken } from "../heartbeat.js"; -import { HEARTBEAT_TOKEN, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; +import { + HEARTBEAT_TOKEN, + isSilentReplyText, + SILENT_REPLY_TOKEN, + stripSilentToken, +} from "../tokens.js"; import type { ReplyPayload } from "../types.js"; import { hasLineDirectives, parseLineDirectives } from "./line-directives.js"; import { @@ -43,6 +48,16 @@ export function normalizeReplyPayload( } text = ""; } + // Strip NO_REPLY from mixed-content messages (e.g. "😄 NO_REPLY") so the + // token never leaks to end users. If stripping leaves nothing, treat it as + // silent just like the exact-match path above. (#30916, #30955) + if (text && text.includes(silentToken) && !isSilentReplyText(text, silentToken)) { + text = stripSilentToken(text, silentToken); + if (!text && !hasMedia && !hasChannelData) { + opts.onSkip?.("silent"); + return null; + } + } if (text && !trimmed) { // Keep empty text when media exists so media-only replies still send. text = ""; diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index fff937187..00c5f02e9 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -108,6 +108,48 @@ describe("normalizeReplyPayload", () => { expect(reasons, testCase.name).toEqual([testCase.reason]); } }); + + it("strips NO_REPLY from mixed emoji message (#30916)", () => { + const result = normalizeReplyPayload({ text: "😄 NO_REPLY" }); + expect(result).not.toBeNull(); + expect(result!.text).toContain("😄"); + expect(result!.text).not.toContain("NO_REPLY"); + }); + + it("strips NO_REPLY appended after substantive text (#30916)", () => { + const result = normalizeReplyPayload({ + text: "File's there. Not urgent.\n\nNO_REPLY", + }); + expect(result).not.toBeNull(); + expect(result!.text).toContain("File's there"); + expect(result!.text).not.toContain("NO_REPLY"); + }); + + it("keeps NO_REPLY when used as leading substantive text", () => { + const result = normalizeReplyPayload({ text: "NO_REPLY -- nope" }); + expect(result).not.toBeNull(); + expect(result!.text).toBe("NO_REPLY -- nope"); + }); + + it("suppresses message when stripping NO_REPLY leaves nothing", () => { + const reasons: string[] = []; + const result = normalizeReplyPayload( + { text: " NO_REPLY " }, + { onSkip: (reason) => reasons.push(reason) }, + ); + expect(result).toBeNull(); + expect(reasons).toEqual(["silent"]); + }); + + it("strips NO_REPLY but keeps media payload", () => { + const result = normalizeReplyPayload({ + text: "NO_REPLY", + mediaUrl: "https://example.com/img.png", + }); + expect(result).not.toBeNull(); + expect(result!.text).toBe(""); + expect(result!.mediaUrl).toBe("https://example.com/img.png"); + }); }); describe("typing controller", () => { diff --git a/src/auto-reply/tokens.test.ts b/src/auto-reply/tokens.test.ts index 262932f82..6dc51d1b7 100644 --- a/src/auto-reply/tokens.test.ts +++ b/src/auto-reply/tokens.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { isSilentReplyPrefixText, isSilentReplyText } from "./tokens.js"; +import { isSilentReplyPrefixText, isSilentReplyText, stripSilentToken } from "./tokens.js"; describe("isSilentReplyText", () => { it("returns true for exact token", () => { @@ -36,6 +36,37 @@ describe("isSilentReplyText", () => { }); }); +describe("stripSilentToken", () => { + it("strips token from end of text", () => { + expect(stripSilentToken("Done.\n\nNO_REPLY")).toBe("Done."); + }); + + it("does not strip token from start of text", () => { + expect(stripSilentToken("NO_REPLY 👍")).toBe("NO_REPLY 👍"); + }); + + it("strips token with emoji (#30916)", () => { + expect(stripSilentToken("😄 NO_REPLY")).toBe("😄"); + }); + + it("does not strip embedded token suffix without whitespace delimiter", () => { + expect(stripSilentToken("interject.NO_REPLY")).toBe("interject.NO_REPLY"); + }); + + it("strips only trailing occurrence", () => { + expect(stripSilentToken("NO_REPLY ok NO_REPLY")).toBe("NO_REPLY ok"); + }); + + it("returns empty string when only token remains", () => { + expect(stripSilentToken("NO_REPLY")).toBe(""); + expect(stripSilentToken(" NO_REPLY ")).toBe(""); + }); + + it("works with custom token", () => { + expect(stripSilentToken("done HEARTBEAT_OK", "HEARTBEAT_OK")).toBe("done"); + }); +}); + describe("isSilentReplyPrefixText", () => { it("matches uppercase underscore prefixes", () => { expect(isSilentReplyPrefixText("NO_")).toBe(true); diff --git a/src/auto-reply/tokens.ts b/src/auto-reply/tokens.ts index 0aeda237e..9be470d64 100644 --- a/src/auto-reply/tokens.ts +++ b/src/auto-reply/tokens.ts @@ -17,6 +17,16 @@ export function isSilentReplyText( return new RegExp(`^\\s*${escaped}\\s*$`).test(text); } +/** + * Strip a trailing silent reply token from mixed-content text. + * Returns the remaining text with the token removed (trimmed). + * If the result is empty, the entire message should be treated as silent. + */ +export function stripSilentToken(text: string, token: string = SILENT_REPLY_TOKEN): string { + const escaped = escapeRegExp(token); + return text.replace(new RegExp(`(?:^|\\s+)${escaped}\\s*$`), "").trim(); +} + export function isSilentReplyPrefixText( text: string | undefined, token: string = SILENT_REPLY_TOKEN,