From e336b7f27ed526b61371cf211abe6b0d5734a8c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 26 Dec 2025 20:39:20 +0000 Subject: [PATCH] fix: use final heartbeat payload --- CHANGELOG.md | 1 + src/infra/heartbeat-runner.test.ts | 64 +++++++++++++++++++++++++++++- src/infra/heartbeat-runner.ts | 23 +++++++++-- src/web/auto-reply.ts | 23 +++++++++-- 4 files changed, 104 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14105a7c9..1d42d8f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### Fixes - Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam. +- Heartbeat delivery now uses the last non-empty payload, preventing tool preambles from swallowing the final reply. - Heartbeat failure logs now include the error reason instead of `[object Object]`. - Duration strings now accept `h` (hours) where durations are parsed (e.g., heartbeat intervals). - WhatsApp inbound now normalizes more wrapper types so quoted reply bodies are extracted reliably. diff --git a/src/infra/heartbeat-runner.test.ts b/src/infra/heartbeat-runner.test.ts index ad26c65db..bd362413c 100644 --- a/src/infra/heartbeat-runner.test.ts +++ b/src/infra/heartbeat-runner.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js"; +import * as replyModule from "../auto-reply/reply.js"; import type { ClawdisConfig } from "../config/config.js"; import { + runHeartbeatOnce, resolveHeartbeatDeliveryTarget, resolveHeartbeatIntervalMs, resolveHeartbeatPrompt, @@ -113,3 +118,60 @@ describe("resolveHeartbeatDeliveryTarget", () => { }); }); }); + +describe("runHeartbeatOnce", () => { + it("uses the last non-empty payload for delivery", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-hb-")); + const storePath = path.join(tmpDir, "sessions.json"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + try { + await fs.writeFile( + storePath, + JSON.stringify( + { + main: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", + }, + }, + null, + 2, + ), + ); + + const cfg: ClawdisConfig = { + agent: { + heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, + }, + routing: { allowFrom: ["*"] }, + session: { store: storePath }, + }; + + replySpy.mockResolvedValue([ + { text: "Let me check..." }, + { text: "Final alert" }, + ]); + const sendWhatsApp = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + await runHeartbeatOnce({ + cfg, + deps: { sendWhatsApp, getQueueSize: () => 0, nowMs: () => 0 }, + }); + + expect(sendWhatsApp).toHaveBeenCalledTimes(1); + expect(sendWhatsApp).toHaveBeenCalledWith( + "+1555", + "Final alert", + expect.any(Object), + ); + } finally { + replySpy.mockRestore(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 5929e5bdc..f2f6dfc6f 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -93,6 +93,25 @@ function resolveHeartbeatSession(cfg: ClawdisConfig) { return { sessionKey, storePath, store, entry }; } +function resolveHeartbeatReplyPayload( + replyResult: ReplyPayload | ReplyPayload[] | undefined, +): ReplyPayload | undefined { + if (!replyResult) return undefined; + if (!Array.isArray(replyResult)) return replyResult; + for (let idx = replyResult.length - 1; idx >= 0; idx -= 1) { + const payload = replyResult[idx]; + if (!payload) continue; + if ( + payload.text || + payload.mediaUrl || + (payload.mediaUrls && payload.mediaUrls.length > 0) + ) { + return payload; + } + } + return undefined; +} + function resolveHeartbeatSender(params: { allowFrom: Array; lastTo?: string; @@ -318,9 +337,7 @@ export async function runHeartbeatOnce(opts: { { isHeartbeat: true }, cfg, ); - const replyPayload = Array.isArray(replyResult) - ? replyResult[0] - : replyResult; + const replyPayload = resolveHeartbeatReplyPayload(replyResult); if ( !replyPayload || diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index a3adbc5f6..4fef1142a 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -190,6 +190,25 @@ function isSilentReply(payload?: ReplyPayload): boolean { return true; } +function resolveHeartbeatReplyPayload( + replyResult: ReplyPayload | ReplyPayload[] | undefined, +): ReplyPayload | undefined { + if (!replyResult) return undefined; + if (!Array.isArray(replyResult)) return replyResult; + for (let idx = replyResult.length - 1; idx >= 0; idx -= 1) { + const payload = replyResult[idx]; + if (!payload) continue; + if ( + payload.text || + payload.mediaUrl || + (payload.mediaUrls && payload.mediaUrls.length > 0) + ) { + return payload; + } + } + return undefined; +} + export async function runWebHeartbeatOnce(opts: { cfg?: ReturnType; to: string; @@ -291,9 +310,7 @@ export async function runWebHeartbeatOnce(opts: { { isHeartbeat: true }, cfg, ); - const replyPayload = Array.isArray(replyResult) - ? replyResult[0] - : replyResult; + const replyPayload = resolveHeartbeatReplyPayload(replyResult); if ( !replyPayload ||