fix(auto-reply): land #31080 from @scoootscooob
Co-authored-by: scoootscooob <zhentongfan@gmail.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user