From 3518554e23680d04d97976cd5ab25ef0766d0a2a Mon Sep 17 00:00:00 2001 From: Sebastian <19554889+sebslight@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:56:32 -0500 Subject: [PATCH] fix(heartbeat): bound responsePrefix strip for ack detection --- CHANGELOG.md | 1 + ...espects-ackmaxchars-heartbeat-acks.test.ts | 49 ++++++++++++++++++- src/infra/heartbeat-runner.ts | 24 +++++++-- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee9cdca4ce..7e7e41e052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Security/Sessions: create new session transcript JSONL files with user-only (`0o600`) permissions and extend `openclaw security audit --fix` to remediate existing transcript file permissions. - Infra/Fetch: ensure foreign abort-signal listener cleanup never masks original fetch successes/failures, while still preventing detached-finally unhandled rejection noise in `wrapFetchWithAbortSignal`. Thanks @Jackten. - Heartbeat: allow suppressing tool error warning payloads during heartbeat runs via a new heartbeat config flag. (#18497) Thanks @thewilloftheshadow. +- Heartbeat/Telegram: strip configured `responsePrefix` before heartbeat ack detection (with boundary-safe matching) so prefixed `HEARTBEAT_OK` replies are correctly suppressed instead of leaking into DMs. (#18602) ## 2026.2.15 diff --git a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts index 6e397011af..c4c5b10919 100644 --- a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts +++ b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts @@ -201,7 +201,7 @@ describe("resolveHeartbeatIntervalMs", () => { }); }); - it("strips responsePrefix before detecting HEARTBEAT_OK and skips telegram delivery", async () => { + it("strips responsePrefix before HEARTBEAT_OK detection and suppresses short ack text", async () => { await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { const cfg = createHeartbeatConfig({ tmpDir, @@ -226,7 +226,7 @@ describe("resolveHeartbeatIntervalMs", () => { lastTo: "12345", }); - replySpy.mockResolvedValue({ text: "[openclaw] HEARTBEAT_OK" }); + replySpy.mockResolvedValue({ text: "[openclaw] HEARTBEAT_OK all good" }); const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "jid", @@ -241,6 +241,51 @@ describe("resolveHeartbeatIntervalMs", () => { }); }); + it("does not strip alphanumeric responsePrefix from larger words", async () => { + await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { + const cfg = createHeartbeatConfig({ + tmpDir, + storePath, + heartbeat: { + every: "5m", + target: "telegram", + }, + channels: { + telegram: { + token: "test-token", + allowFrom: ["*"], + heartbeat: { showOk: false }, + }, + }, + messages: { responsePrefix: "Hi" }, + }); + + await seedMainSession(storePath, cfg, { + lastChannel: "telegram", + lastProvider: "telegram", + lastTo: "12345", + }); + + replySpy.mockResolvedValue({ text: "History check complete" }); + const sendTelegram = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + await runHeartbeatOnce({ + cfg, + deps: makeTelegramDeps({ sendTelegram }), + }); + + expect(sendTelegram).toHaveBeenCalledTimes(1); + expect(sendTelegram).toHaveBeenCalledWith( + "12345", + "History check complete", + expect.any(Object), + ); + }); + }); + it("skips heartbeat LLM calls when visibility disables all output", async () => { await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { const cfg = createHeartbeatConfig({ diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 65bd6f4b0b..fef8972bcc 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -430,17 +430,31 @@ async function captureTranscriptState(params: { } } +function stripLeadingHeartbeatResponsePrefix( + text: string, + responsePrefix: string | undefined, +): string { + const normalizedPrefix = responsePrefix?.trim(); + if (!normalizedPrefix) { + return text; + } + + // Require a boundary after the configured prefix so short prefixes like "Hi" + // do not strip the beginning of normal words like "History". + const prefixPattern = new RegExp( + `^${escapeRegExp(normalizedPrefix)}(?=$|\\s|[\\p{P}\\p{S}])\\s*`, + "iu", + ); + return text.replace(prefixPattern, ""); +} + function normalizeHeartbeatReply( payload: ReplyPayload, responsePrefix: string | undefined, ackMaxChars: number, ) { const rawText = typeof payload.text === "string" ? payload.text : ""; - - const prefixPattern = responsePrefix?.trim() - ? new RegExp(`^${escapeRegExp(responsePrefix.trim())}\\s*`, "i") - : null; - const textForStrip = prefixPattern ? rawText.replace(prefixPattern, "") : rawText; + const textForStrip = stripLeadingHeartbeatResponsePrefix(rawText, responsePrefix); const stripped = stripHeartbeatToken(textForStrip, { mode: "heartbeat", maxAckChars: ackMaxChars,