fix(heartbeat): bound responsePrefix strip for ack detection

This commit is contained in:
Sebastian
2026-02-16 20:56:32 -05:00
parent c219c85df3
commit 3518554e23
3 changed files with 67 additions and 7 deletions

View File

@@ -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

View File

@@ -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({

View File

@@ -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,