fix: filter NO_REPLY prefixes

This commit is contained in:
Peter Steinberger
2026-01-09 23:29:01 +00:00
parent a9a70ea278
commit 96e17d407a
11 changed files with 85 additions and 16 deletions

View File

@@ -32,7 +32,7 @@ import {
import { stripHeartbeatToken } from "../heartbeat.js";
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { extractAudioTag } from "./audio-tags.js";
import { createBlockReplyPipeline } from "./block-reply-pipeline.js";
@@ -536,7 +536,10 @@ export async function runReplyAgent(params: {
Boolean(taggedPayload.mediaUrl) ||
(taggedPayload.mediaUrls?.length ?? 0) > 0;
if (!cleaned && !hasMedia) return;
if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia)
if (
isSilentReplyText(cleaned, SILENT_REPLY_TOKEN) &&
!hasMedia
)
return;
const blockPayload: ReplyPayload = applyReplyToMode({
...taggedPayload,
@@ -745,7 +748,8 @@ export async function runReplyAgent(params: {
const shouldSignalTyping = replyPayloads.some((payload) => {
const trimmed = payload.text?.trim();
if (trimmed && trimmed !== SILENT_REPLY_TOKEN) return true;
if (trimmed && !isSilentReplyText(trimmed, SILENT_REPLY_TOKEN))
return true;
if (payload.mediaUrl) return true;
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
return false;

View File

@@ -11,7 +11,7 @@ import { registerAgentRunContext } from "../../infra/agent-events.js";
import { defaultRuntime } from "../../runtime.js";
import { stripHeartbeatToken } from "../heartbeat.js";
import type { OriginatingChannelType } from "../templating.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import type { FollowupRun } from "./queue.js";
import {
@@ -80,7 +80,7 @@ export function createFollowupRunner(params: {
continue;
}
if (
payload.text?.trim() === SILENT_REPLY_TOKEN &&
isSilentReplyText(payload.text, SILENT_REPLY_TOKEN) &&
!payload.mediaUrl &&
!payload.mediaUrls?.length
) {

View File

@@ -215,7 +215,7 @@ export function buildGroupIntro(params: {
: "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included).";
const silenceLine =
activation === "always"
? `If no response is needed, reply with exactly "${params.silentToken}" (no other text) so Clawdbot stays silent.`
? `If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so Clawdbot stays silent. Do not add any other words, punctuation, tags, markdown/code blocks, or explanations.`
: undefined;
const cautionLine =
activation === "always"

View File

@@ -1,5 +1,9 @@
import { stripHeartbeatToken } from "../heartbeat.js";
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
import {
HEARTBEAT_TOKEN,
isSilentReplyText,
SILENT_REPLY_TOKEN,
} from "../tokens.js";
import type { ReplyPayload } from "../types.js";
export type NormalizeReplyOptions = {
@@ -20,9 +24,11 @@ export function normalizeReplyPayload(
if (!trimmed && !hasMedia) return null;
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
if (trimmed === silentToken && !hasMedia) return null;
let text = payload.text ?? undefined;
if (text && isSilentReplyText(text, silentToken)) {
if (!hasMedia) return null;
text = "";
}
if (text && !trimmed) {
// Keep empty text when media exists so media-only replies still send.
text = "";

View File

@@ -10,6 +10,9 @@ describe("createReplyDispatcher", () => {
expect(dispatcher.sendFinalReply({})).toBe(false);
expect(dispatcher.sendFinalReply({ text: " " })).toBe(false);
expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false);
expect(
dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` }),
).toBe(false);
await dispatcher.waitForIdle();
expect(deliver).not.toHaveBeenCalled();
@@ -54,12 +57,19 @@ describe("createReplyDispatcher", () => {
mediaUrl: "file:///tmp/photo.jpg",
}),
).toBe(true);
expect(
dispatcher.sendFinalReply({
text: `${SILENT_REPLY_TOKEN} -- explanation`,
mediaUrl: "file:///tmp/photo.jpg",
}),
).toBe(true);
await dispatcher.waitForIdle();
expect(deliver).toHaveBeenCalledTimes(2);
expect(deliver).toHaveBeenCalledTimes(3);
expect(deliver.mock.calls[0][0].text).toBe("PFX already");
expect(deliver.mock.calls[1][0].text).toBe("");
expect(deliver.mock.calls[2][0].text).toBe("");
});
it("preserves ordering across tool, block, and final replies", async () => {

View File

@@ -81,6 +81,18 @@ describe("routeReply", () => {
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
});
it("drops payloads that start with the silent token", async () => {
mocks.sendMessageSlack.mockClear();
const res = await routeReply({
payload: { text: `${SILENT_REPLY_TOKEN} -- (why am I here?)` },
channel: "slack",
to: "channel:C123",
cfg: {} as never,
});
expect(res.ok).toBe(true);
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
});
it("applies responsePrefix when routing", async () => {
mocks.sendMessageSlack.mockClear();
const cfg = {

View File

@@ -1,2 +1,15 @@
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
export const SILENT_REPLY_TOKEN = "NO_REPLY";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function isSilentReplyText(
text: string | undefined,
token: string = SILENT_REPLY_TOKEN,
): boolean {
if (!text) return false;
const re = new RegExp(`^\\s*${escapeRegExp(token)}(?=$|\\W)`);
return re.test(text);
}