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 e01211c22..5c401c402 100644 --- a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts +++ b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import * as replyModule from "../auto-reply/reply.js"; import { resolveMainSessionKey } from "../config/sessions.js"; -import { runHeartbeatOnce } from "./heartbeat-runner.js"; +import { runHeartbeatOnce, type HeartbeatDeps } from "./heartbeat-runner.js"; import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js"; // Avoid pulling optional runtime deps during isolated runs. @@ -19,6 +19,7 @@ describe("resolveHeartbeatIntervalMs", () => { storePath: string; heartbeat: Record; channels: Record; + messages?: Record; }): OpenClawConfig { return { agents: { @@ -28,6 +29,7 @@ describe("resolveHeartbeatIntervalMs", () => { }, }, channels: params.channels as never, + ...(params.messages ? { messages: params.messages as never } : {}), session: { store: params.storePath }, }; } @@ -58,12 +60,14 @@ describe("resolveHeartbeatIntervalMs", () => { } = {}, ) { return { - ...(params.sendWhatsApp ? { sendWhatsApp: params.sendWhatsApp } : {}), + ...(params.sendWhatsApp + ? { sendWhatsApp: params.sendWhatsApp as unknown as HeartbeatDeps["sendWhatsApp"] } + : {}), getQueueSize: params.getQueueSize ?? (() => 0), nowMs: params.nowMs ?? (() => 0), webAuthExists: params.webAuthExists ?? (async () => true), hasActiveWebListener: params.hasActiveWebListener ?? (() => true), - }; + } satisfies HeartbeatDeps; } function makeTelegramDeps( @@ -74,10 +78,12 @@ describe("resolveHeartbeatIntervalMs", () => { } = {}, ) { return { - ...(params.sendTelegram ? { sendTelegram: params.sendTelegram } : {}), + ...(params.sendTelegram + ? { sendTelegram: params.sendTelegram as unknown as HeartbeatDeps["sendTelegram"] } + : {}), getQueueSize: params.getQueueSize ?? (() => 0), nowMs: params.nowMs ?? (() => 0), - }; + } satisfies HeartbeatDeps; } async function seedSessionStore( @@ -252,6 +258,46 @@ describe("resolveHeartbeatIntervalMs", () => { }); }); + it("strips responsePrefix before detecting HEARTBEAT_OK and skips telegram delivery", 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: "[openclaw]" }, + }); + + await seedMainSession(storePath, cfg, { + lastChannel: "telegram", + lastProvider: "telegram", + lastTo: "12345", + }); + + replySpy.mockResolvedValue({ text: "[openclaw] HEARTBEAT_OK" }); + const sendTelegram = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + await runHeartbeatOnce({ + cfg, + deps: makeTelegramDeps({ sendTelegram }), + }); + + expect(sendTelegram).not.toHaveBeenCalled(); + }); + }); + 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 b83693c2f..b3f187ce9 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -40,6 +40,7 @@ import { getQueueSize } from "../process/command-queue.js"; import { CommandLane } from "../process/lanes.js"; import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { escapeRegExp } from "../utils.js"; import { formatErrorMessage } from "./errors.js"; import { isWithinActiveHours } from "./heartbeat-active-hours.js"; import { @@ -62,7 +63,7 @@ import { } from "./outbound/targets.js"; import { peekSystemEventEntries } from "./system-events.js"; -type HeartbeatDeps = OutboundSendDeps & +export type HeartbeatDeps = OutboundSendDeps & ChannelHeartbeatDeps & { runtime?: RuntimeEnv; getQueueSize?: (lane?: string) => number; @@ -355,7 +356,13 @@ function normalizeHeartbeatReply( responsePrefix: string | undefined, ackMaxChars: number, ) { - const stripped = stripHeartbeatToken(payload.text, { + const rawText = typeof payload.text === "string" ? payload.text : ""; + // Normalize away responsePrefix so a prefixed HEARTBEAT_OK still strips. + const prefixPattern = responsePrefix?.trim() + ? new RegExp(`^${escapeRegExp(responsePrefix.trim())}\\s*`, "i") + : null; + const textForStrip = prefixPattern ? rawText.replace(prefixPattern, "") : rawText; + const stripped = stripHeartbeatToken(textForStrip, { mode: "heartbeat", maxAckChars: ackMaxChars, });