Skip to content

Commit 2116d31

Browse files
committed
fix(heartbeat): suppress no-op system event replies
Suppress exact silent/no-op sentinel outputs from heartbeat and system-event handoffs so HEARTBEAT_OK and NO_REPLY-style responses do not leak as visible messages. Keep meaningful exec completion summaries deliverable.\n\nFixes #73149.
1 parent 7b2b0d0 commit 2116d31

3 files changed

Lines changed: 145 additions & 17 deletions

File tree

src/auto-reply/tokens.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { escapeRegExp } from "../shared/regexp.js";
22

33
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
44
export const SILENT_REPLY_TOKEN = "NO_REPLY";
5+
export const NO_OP_SENTINEL_REPLY_TOKENS = [
6+
SILENT_REPLY_TOKEN,
7+
"NO_NEW_AUDIO",
8+
"SESSION_WATCHDOG_OK",
9+
] as const;
510

611
const silentExactRegexByToken = new Map<string, RegExp>();
712
const silentTrailingRegexByToken = new Map<string, RegExp>();
@@ -78,6 +83,33 @@ export function isSilentReplyPayloadText(
7883
return isSilentReplyText(text, token) || isSilentReplyEnvelopeText(text, token);
7984
}
8085

86+
function normalizeSentinelReplyText(text: string): string {
87+
return text
88+
.trim()
89+
.replace(/<[^>]*>/g, " ")
90+
.replace(/&nbsp;/gi, " ")
91+
.replace(/^[*`~_]+/, "")
92+
.replace(/[*`~_]+$/, "")
93+
.trim();
94+
}
95+
96+
export function isNoOpSentinelReplyText(
97+
text: string | undefined,
98+
opts: { includeHeartbeat?: boolean } = {},
99+
): boolean {
100+
if (!text) {
101+
return false;
102+
}
103+
if (isSilentReplyPayloadText(text)) {
104+
return true;
105+
}
106+
const normalized = normalizeSentinelReplyText(text);
107+
if (opts.includeHeartbeat && isSilentReplyText(normalized, HEARTBEAT_TOKEN)) {
108+
return true;
109+
}
110+
return NO_OP_SENTINEL_REPLY_TOKENS.some((token) => isSilentReplyText(normalized, token));
111+
}
112+
81113
/**
82114
* Strip a trailing silent reply token from mixed-content text.
83115
* Returns the remaining text with the token removed (trimmed).

src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import fs from "node:fs/promises";
2-
import { describe, expect, it, vi } from "vitest";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
33
import type { OpenClawConfig } from "../config/config.js";
44
import { runHeartbeatOnce, type HeartbeatDeps } from "./heartbeat-runner.js";
55
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
@@ -9,8 +9,10 @@ import {
99
withTempHeartbeatSandbox,
1010
withTempTelegramHeartbeatSandbox,
1111
} from "./heartbeat-runner.test-utils.js";
12+
import { enqueueSystemEvent, resetSystemEventsForTest } from "./system-events.js";
1213

1314
installHeartbeatRunnerTestRuntime();
15+
beforeEach(() => resetSystemEventsForTest());
1416

1517
describe("runHeartbeatOnce ack handling", () => {
1618
const WHATSAPP_GROUP = "120363140186826074@g.us";
@@ -302,6 +304,102 @@ describe("runHeartbeatOnce ack handling", () => {
302304
});
303305
});
304306

307+
it("suppresses exact no-op sentinel heartbeat replies", async () => {
308+
await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
309+
const cfg = await createSeededWhatsAppHeartbeatConfig({
310+
tmpDir,
311+
storePath,
312+
});
313+
314+
const sendWhatsApp = createMessageSendSpy();
315+
const cases = ["NO_REPLY", "NO_NEW_AUDIO", "SESSION_WATCHDOG_OK"];
316+
for (const replyText of cases) {
317+
replySpy.mockResolvedValueOnce({ text: replyText });
318+
await runHeartbeatOnce({
319+
cfg,
320+
deps: {
321+
...makeWhatsAppDeps({ sendWhatsApp }),
322+
getReplyFromConfig: replySpy,
323+
},
324+
});
325+
}
326+
327+
expect(sendWhatsApp).not.toHaveBeenCalled();
328+
});
329+
});
330+
331+
it("suppresses exact HEARTBEAT_OK from exec system event handoff", async () => {
332+
await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
333+
const cfg = await createSeededWhatsAppHeartbeatConfig({
334+
tmpDir,
335+
storePath,
336+
});
337+
const sessionKey = await seedMainSessionStore(storePath, cfg, {
338+
lastChannel: "whatsapp",
339+
lastProvider: "whatsapp",
340+
lastTo: WHATSAPP_GROUP,
341+
});
342+
enqueueSystemEvent("Exec completed (abc12345, code 0) :: no output", {
343+
sessionKey,
344+
contextKey: "exec:abc12345",
345+
});
346+
347+
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
348+
const sendWhatsApp = createMessageSendSpy();
349+
350+
await runHeartbeatOnce({
351+
cfg,
352+
reason: "exec-event",
353+
deps: {
354+
...makeWhatsAppDeps({ sendWhatsApp }),
355+
getReplyFromConfig: replySpy,
356+
},
357+
});
358+
359+
expect(sendWhatsApp).not.toHaveBeenCalled();
360+
});
361+
});
362+
363+
it("keeps meaningful exec system event summaries", async () => {
364+
await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
365+
const cfg = await createSeededWhatsAppHeartbeatConfig({
366+
tmpDir,
367+
storePath,
368+
});
369+
const sessionKey = await seedMainSessionStore(storePath, cfg, {
370+
lastChannel: "whatsapp",
371+
lastProvider: "whatsapp",
372+
lastTo: WHATSAPP_GROUP,
373+
});
374+
enqueueSystemEvent("Exec completed (abc12345, code 0) :: uploaded report.txt", {
375+
sessionKey,
376+
contextKey: "exec:abc12345",
377+
});
378+
379+
replySpy.mockResolvedValue({ text: "Command completed: uploaded report.txt" });
380+
const sendWhatsApp = createMessageSendSpy();
381+
382+
await runHeartbeatOnce({
383+
cfg,
384+
reason: "exec-event",
385+
deps: {
386+
...makeWhatsAppDeps({ sendWhatsApp }),
387+
getReplyFromConfig: replySpy,
388+
},
389+
});
390+
391+
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
392+
expect(sendWhatsApp).toHaveBeenCalledWith(
393+
WHATSAPP_GROUP,
394+
"Command completed: uploaded report.txt",
395+
expect.any(Object),
396+
);
397+
const calledCtx = replySpy.mock.calls[0]?.[0] as { Body?: string; Provider?: string };
398+
expect(calledCtx.Provider).toBe("exec-event");
399+
expect(calledCtx.Body).toContain("Please relay the command output to the user");
400+
});
401+
});
402+
305403
it("does not regress updatedAt when restoring heartbeat sessions", async () => {
306404
await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
307405
const originalUpdatedAt = 1000;

src/infra/heartbeat-runner.ts

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
stripHeartbeatToken,
2525
type HeartbeatTask,
2626
} from "../auto-reply/heartbeat.js";
27-
import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
27+
import { HEARTBEAT_TOKEN, isNoOpSentinelReplyText } from "../auto-reply/tokens.js";
2828
import type { ReplyPayload } from "../auto-reply/types.js";
2929
import { getChannelPlugin } from "../channels/plugins/index.js";
3030
import type {
@@ -498,11 +498,19 @@ function normalizeHeartbeatReply(
498498
payload: ReplyPayload,
499499
responsePrefix: string | undefined,
500500
ackMaxChars: number,
501+
opts: { systemEvent?: boolean } = {},
501502
) {
502503
const rawText = typeof payload.text === "string" ? payload.text : "";
503504
const textForStrip = stripLeadingHeartbeatResponsePrefix(rawText, responsePrefix);
505+
if (isNoOpSentinelReplyText(textForStrip, { includeHeartbeat: true })) {
506+
return {
507+
shouldSkip: true,
508+
text: "",
509+
hasMedia: resolveSendableOutboundReplyParts(payload).hasMedia,
510+
};
511+
}
504512
const stripped = stripHeartbeatToken(textForStrip, {
505-
mode: "heartbeat",
513+
mode: opts.systemEvent ? "message" : "heartbeat",
506514
maxAckChars: ackMaxChars,
507515
});
508516
const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia;
@@ -1106,20 +1114,10 @@ export async function runHeartbeatOnce(opts: {
11061114
}
11071115

11081116
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
1109-
const normalized = normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars);
1110-
// For exec completion events, don't skip even if the response looks like HEARTBEAT_OK.
1111-
// The model should be responding with exec results, not ack tokens.
1112-
// Also, if normalized.text is empty due to token stripping but we have exec completion,
1113-
// fall back to the original reply text.
1114-
const execFallbackText =
1115-
hasExecCompletion && !normalized.text.trim() && replyPayload.text?.trim()
1116-
? replyPayload.text.trim()
1117-
: null;
1118-
if (execFallbackText) {
1119-
normalized.text = execFallbackText;
1120-
normalized.shouldSkip = false;
1121-
}
1122-
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia && !hasExecCompletion;
1117+
const normalized = normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars, {
1118+
systemEvent: hasExecCompletion || hasCronEvents,
1119+
});
1120+
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
11231121
if (shouldSkipMain && reasoningPayloads.length === 0) {
11241122
await restoreHeartbeatUpdatedAt({
11251123
storePath,

0 commit comments

Comments
 (0)