Fix #12767: Heartbeat strip responsePrefix before HEARTBEAT_OK suppression

This commit is contained in:
Jean Carlos Nunez
2026-02-16 17:03:25 -05:00
committed by Peter Steinberger
parent feed570984
commit f476c8b48b
2 changed files with 60 additions and 7 deletions

View File

@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import * as replyModule from "../auto-reply/reply.js"; import * as replyModule from "../auto-reply/reply.js";
import { resolveMainSessionKey } from "../config/sessions.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"; import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
// Avoid pulling optional runtime deps during isolated runs. // Avoid pulling optional runtime deps during isolated runs.
@@ -19,6 +19,7 @@ describe("resolveHeartbeatIntervalMs", () => {
storePath: string; storePath: string;
heartbeat: Record<string, unknown>; heartbeat: Record<string, unknown>;
channels: Record<string, unknown>; channels: Record<string, unknown>;
messages?: Record<string, unknown>;
}): OpenClawConfig { }): OpenClawConfig {
return { return {
agents: { agents: {
@@ -28,6 +29,7 @@ describe("resolveHeartbeatIntervalMs", () => {
}, },
}, },
channels: params.channels as never, channels: params.channels as never,
...(params.messages ? { messages: params.messages as never } : {}),
session: { store: params.storePath }, session: { store: params.storePath },
}; };
} }
@@ -58,12 +60,14 @@ describe("resolveHeartbeatIntervalMs", () => {
} = {}, } = {},
) { ) {
return { return {
...(params.sendWhatsApp ? { sendWhatsApp: params.sendWhatsApp } : {}), ...(params.sendWhatsApp
? { sendWhatsApp: params.sendWhatsApp as unknown as HeartbeatDeps["sendWhatsApp"] }
: {}),
getQueueSize: params.getQueueSize ?? (() => 0), getQueueSize: params.getQueueSize ?? (() => 0),
nowMs: params.nowMs ?? (() => 0), nowMs: params.nowMs ?? (() => 0),
webAuthExists: params.webAuthExists ?? (async () => true), webAuthExists: params.webAuthExists ?? (async () => true),
hasActiveWebListener: params.hasActiveWebListener ?? (() => true), hasActiveWebListener: params.hasActiveWebListener ?? (() => true),
}; } satisfies HeartbeatDeps;
} }
function makeTelegramDeps( function makeTelegramDeps(
@@ -74,10 +78,12 @@ describe("resolveHeartbeatIntervalMs", () => {
} = {}, } = {},
) { ) {
return { return {
...(params.sendTelegram ? { sendTelegram: params.sendTelegram } : {}), ...(params.sendTelegram
? { sendTelegram: params.sendTelegram as unknown as HeartbeatDeps["sendTelegram"] }
: {}),
getQueueSize: params.getQueueSize ?? (() => 0), getQueueSize: params.getQueueSize ?? (() => 0),
nowMs: params.nowMs ?? (() => 0), nowMs: params.nowMs ?? (() => 0),
}; } satisfies HeartbeatDeps;
} }
async function seedSessionStore( 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 () => { it("skips heartbeat LLM calls when visibility disables all output", async () => {
await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
const cfg = createHeartbeatConfig({ const cfg = createHeartbeatConfig({

View File

@@ -40,6 +40,7 @@ import { getQueueSize } from "../process/command-queue.js";
import { CommandLane } from "../process/lanes.js"; import { CommandLane } from "../process/lanes.js";
import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js"; import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { escapeRegExp } from "../utils.js";
import { formatErrorMessage } from "./errors.js"; import { formatErrorMessage } from "./errors.js";
import { isWithinActiveHours } from "./heartbeat-active-hours.js"; import { isWithinActiveHours } from "./heartbeat-active-hours.js";
import { import {
@@ -62,7 +63,7 @@ import {
} from "./outbound/targets.js"; } from "./outbound/targets.js";
import { peekSystemEventEntries } from "./system-events.js"; import { peekSystemEventEntries } from "./system-events.js";
type HeartbeatDeps = OutboundSendDeps & export type HeartbeatDeps = OutboundSendDeps &
ChannelHeartbeatDeps & { ChannelHeartbeatDeps & {
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
getQueueSize?: (lane?: string) => number; getQueueSize?: (lane?: string) => number;
@@ -355,7 +356,13 @@ function normalizeHeartbeatReply(
responsePrefix: string | undefined, responsePrefix: string | undefined,
ackMaxChars: number, 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", mode: "heartbeat",
maxAckChars: ackMaxChars, maxAckChars: ackMaxChars,
}); });