feat(heartbeat): add configurable visibility for heartbeat responses

Add per-channel and per-account heartbeat visibility settings:
- showOk: hide/show HEARTBEAT_OK messages (default: false)
- showAlerts: hide/show alert messages (default: true)
- useIndicator: emit typing indicator events (default: true)

Config precedence: per-account > per-channel > channel-defaults > global

This allows silencing routine heartbeat acks while still surfacing
alerts when something needs attention.
This commit is contained in:
Dave Lauer
2026-01-22 10:54:07 -05:00
committed by Peter Steinberger
parent 9b12275fe1
commit f9cf508cff
18 changed files with 695 additions and 6 deletions

View File

@@ -1,3 +1,5 @@
export type HeartbeatIndicatorType = "ok" | "alert" | "error";
export type HeartbeatEventPayload = {
ts: number;
status: "sent" | "ok-empty" | "ok-token" | "skipped" | "failed";
@@ -6,8 +8,30 @@ export type HeartbeatEventPayload = {
durationMs?: number;
hasMedia?: boolean;
reason?: string;
/** The channel this heartbeat was sent to. */
channel?: string;
/** Whether the message was silently suppressed (showOk: false). */
silent?: boolean;
/** Indicator type for UI status display. */
indicatorType?: HeartbeatIndicatorType;
};
export function resolveIndicatorType(
status: HeartbeatEventPayload["status"],
): HeartbeatIndicatorType | undefined {
switch (status) {
case "ok-empty":
case "ok-token":
return "ok";
case "sent":
return "alert";
case "failed":
return "error";
case "skipped":
return undefined;
}
}
let lastHeartbeat: HeartbeatEventPayload | null = null;
const listeners = new Set<(evt: HeartbeatEventPayload) => void>();

View File

@@ -92,6 +92,135 @@ describe("resolveHeartbeatIntervalMs", () => {
}
});
it("sends HEARTBEAT_OK when visibility.showOk is true", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "whatsapp",
},
},
},
channels: { whatsapp: { allowFrom: ["*"], heartbeat: { showOk: true } } },
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
);
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
const sendWhatsApp = vi.fn().mockResolvedValue({
messageId: "m1",
toJid: "jid",
});
await runHeartbeatOnce({
cfg,
deps: {
sendWhatsApp,
getQueueSize: () => 0,
nowMs: () => 0,
webAuthExists: async () => true,
hasActiveWebListener: () => true,
},
});
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "HEARTBEAT_OK", expect.any(Object));
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("skips heartbeat LLM calls when visibility disables all output", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "whatsapp",
},
},
},
channels: {
whatsapp: {
allowFrom: ["*"],
heartbeat: { showOk: false, showAlerts: false, useIndicator: false },
},
},
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
);
const sendWhatsApp = vi.fn().mockResolvedValue({
messageId: "m1",
toJid: "jid",
});
const result = await runHeartbeatOnce({
cfg,
deps: {
sendWhatsApp,
getQueueSize: () => 0,
nowMs: () => 0,
webAuthExists: async () => true,
hasActiveWebListener: () => true,
},
});
expect(replySpy).not.toHaveBeenCalled();
expect(sendWhatsApp).not.toHaveBeenCalled();
expect(result).toEqual({ status: "skipped", reason: "alerts-disabled" });
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("skips delivery for markup-wrapped HEARTBEAT_OK", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");

View File

@@ -16,6 +16,7 @@ import {
resolveHeartbeatPrompt as resolveHeartbeatPromptText,
stripHeartbeatToken,
} from "../auto-reply/heartbeat.js";
import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
@@ -39,7 +40,8 @@ import { getQueueSize } from "../process/command-queue.js";
import { CommandLane } from "../process/lanes.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
import { emitHeartbeatEvent } from "./heartbeat-events.js";
import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js";
import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js";
import {
type HeartbeatRunResult,
type HeartbeatWakeHandler,
@@ -471,7 +473,16 @@ export async function runHeartbeatOnce(opts: {
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
const previousUpdatedAt = entry?.updatedAt;
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
const visibility =
delivery.channel !== "none"
? resolveHeartbeatVisibility({
cfg,
channel: delivery.channel,
accountId: delivery.accountId,
})
: { showOk: false, showAlerts: true, useIndicator: true };
const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery });
const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix;
const prompt = resolveHeartbeatPrompt(cfg, heartbeat);
const ctx = {
Body: prompt,
@@ -480,6 +491,43 @@ export async function runHeartbeatOnce(opts: {
Provider: "heartbeat",
SessionKey: sessionKey,
};
if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) {
emitHeartbeatEvent({
status: "skipped",
reason: "alerts-disabled",
durationMs: Date.now() - startedAt,
channel: delivery.channel !== "none" ? delivery.channel : undefined,
});
return { status: "skipped", reason: "alerts-disabled" };
}
const heartbeatOkText = responsePrefix
? `${responsePrefix} ${HEARTBEAT_TOKEN}`
: HEARTBEAT_TOKEN;
const canAttemptHeartbeatOk = Boolean(
visibility.showOk && delivery.channel !== "none" && delivery.to,
);
const maybeSendHeartbeatOk = async () => {
if (!canAttemptHeartbeatOk || delivery.channel === "none" || !delivery.to) return false;
const heartbeatPlugin = getChannelPlugin(delivery.channel);
if (heartbeatPlugin?.heartbeat?.checkReady) {
const readiness = await heartbeatPlugin.heartbeat.checkReady({
cfg,
accountId: delivery.accountId,
deps: opts.deps,
});
if (!readiness.ok) return false;
}
await deliverOutboundPayloads({
cfg,
channel: delivery.channel,
to: delivery.to,
accountId: delivery.accountId,
payloads: [{ text: heartbeatOkText }],
deps: opts.deps,
});
return true;
};
try {
const replyResult = await getReplyFromConfig(ctx, { isHeartbeat: true }, cfg);
@@ -498,10 +546,14 @@ export async function runHeartbeatOnce(opts: {
sessionKey,
updatedAt: previousUpdatedAt,
});
const okSent = await maybeSendHeartbeatOk();
emitHeartbeatEvent({
status: "ok-empty",
reason: opts.reason,
durationMs: Date.now() - startedAt,
channel: delivery.channel !== "none" ? delivery.channel : undefined,
silent: !okSent,
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-empty") : undefined,
});
return { status: "ran", durationMs: Date.now() - startedAt };
}
@@ -509,7 +561,7 @@ export async function runHeartbeatOnce(opts: {
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
const normalized = normalizeHeartbeatReply(
replyPayload,
resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
responsePrefix,
ackMaxChars,
);
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
@@ -519,10 +571,14 @@ export async function runHeartbeatOnce(opts: {
sessionKey,
updatedAt: previousUpdatedAt,
});
const okSent = await maybeSendHeartbeatOk();
emitHeartbeatEvent({
status: "ok-token",
reason: opts.reason,
durationMs: Date.now() - startedAt,
channel: delivery.channel !== "none" ? delivery.channel : undefined,
silent: !okSent,
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-token") : undefined,
});
return { status: "ran", durationMs: Date.now() - startedAt };
}
@@ -556,6 +612,7 @@ export async function runHeartbeatOnce(opts: {
preview: normalized.text.slice(0, 200),
durationMs: Date.now() - startedAt,
hasMedia: false,
channel: delivery.channel !== "none" ? delivery.channel : undefined,
});
return { status: "ran", durationMs: Date.now() - startedAt };
}
@@ -579,6 +636,20 @@ export async function runHeartbeatOnce(opts: {
return { status: "ran", durationMs: Date.now() - startedAt };
}
if (!visibility.showAlerts) {
await restoreHeartbeatUpdatedAt({ storePath, sessionKey, updatedAt: previousUpdatedAt });
emitHeartbeatEvent({
status: "skipped",
reason: "alerts-disabled",
preview: previewText?.slice(0, 200),
durationMs: Date.now() - startedAt,
channel: delivery.channel,
hasMedia: mediaUrls.length > 0,
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
});
return { status: "ran", durationMs: Date.now() - startedAt };
}
const deliveryAccountId = delivery.accountId;
const heartbeatPlugin = getChannelPlugin(delivery.channel);
if (heartbeatPlugin?.heartbeat?.checkReady) {
@@ -594,6 +665,7 @@ export async function runHeartbeatOnce(opts: {
preview: previewText?.slice(0, 200),
durationMs: Date.now() - startedAt,
hasMedia: mediaUrls.length > 0,
channel: delivery.channel,
});
log.info("heartbeat: channel not ready", {
channel: delivery.channel,
@@ -642,6 +714,8 @@ export async function runHeartbeatOnce(opts: {
preview: previewText?.slice(0, 200),
durationMs: Date.now() - startedAt,
hasMedia: mediaUrls.length > 0,
channel: delivery.channel,
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
});
return { status: "ran", durationMs: Date.now() - startedAt };
} catch (err) {
@@ -650,6 +724,8 @@ export async function runHeartbeatOnce(opts: {
status: "failed",
reason,
durationMs: Date.now() - startedAt,
channel: delivery.channel !== "none" ? delivery.channel : undefined,
indicatorType: visibility.useIndicator ? resolveIndicatorType("failed") : undefined,
});
log.error(`heartbeat failed: ${reason}`, { error: reason });
return { status: "failed", reason };

View File

@@ -0,0 +1,250 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js";
describe("resolveHeartbeatVisibility", () => {
it("returns default values when no config is provided", () => {
const cfg = {} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" });
expect(result).toEqual({
showOk: false,
showAlerts: true,
useIndicator: true,
});
});
it("uses channel defaults when provided", () => {
const cfg = {
channels: {
defaults: {
heartbeat: {
showOk: true,
showAlerts: false,
useIndicator: false,
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" });
expect(result).toEqual({
showOk: true,
showAlerts: false,
useIndicator: false,
});
});
it("per-channel config overrides channel defaults", () => {
const cfg = {
channels: {
defaults: {
heartbeat: {
showOk: false,
showAlerts: true,
useIndicator: true,
},
},
telegram: {
heartbeat: {
showOk: true,
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" });
expect(result).toEqual({
showOk: true,
showAlerts: true,
useIndicator: true,
});
});
it("per-account config overrides per-channel config", () => {
const cfg = {
channels: {
defaults: {
heartbeat: {
showOk: false,
showAlerts: true,
useIndicator: true,
},
},
telegram: {
heartbeat: {
showOk: false,
showAlerts: false,
},
accounts: {
primary: {
heartbeat: {
showOk: true,
showAlerts: true,
},
},
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({
cfg,
channel: "telegram",
accountId: "primary",
});
expect(result).toEqual({
showOk: true,
showAlerts: true,
useIndicator: true,
});
});
it("falls through to defaults when account has no heartbeat config", () => {
const cfg = {
channels: {
defaults: {
heartbeat: {
showOk: false,
},
},
telegram: {
heartbeat: {
showAlerts: false,
},
accounts: {
primary: {},
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({
cfg,
channel: "telegram",
accountId: "primary",
});
expect(result).toEqual({
showOk: false,
showAlerts: false,
useIndicator: true,
});
});
it("handles missing accountId gracefully", () => {
const cfg = {
channels: {
telegram: {
heartbeat: {
showOk: true,
},
accounts: {
primary: {
heartbeat: {
showOk: false,
},
},
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" });
expect(result.showOk).toBe(true);
});
it("handles non-existent account gracefully", () => {
const cfg = {
channels: {
telegram: {
heartbeat: {
showOk: true,
},
accounts: {
primary: {
heartbeat: {
showOk: false,
},
},
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({
cfg,
channel: "telegram",
accountId: "nonexistent",
});
expect(result.showOk).toBe(true);
});
it("works with whatsapp channel", () => {
const cfg = {
channels: {
whatsapp: {
heartbeat: {
showOk: true,
showAlerts: false,
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "whatsapp" });
expect(result).toEqual({
showOk: true,
showAlerts: false,
useIndicator: true,
});
});
it("works with discord channel", () => {
const cfg = {
channels: {
discord: {
heartbeat: {
useIndicator: false,
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "discord" });
expect(result).toEqual({
showOk: false,
showAlerts: true,
useIndicator: false,
});
});
it("works with slack channel", () => {
const cfg = {
channels: {
slack: {
heartbeat: {
showOk: true,
showAlerts: true,
useIndicator: true,
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "slack" });
expect(result).toEqual({
showOk: true,
showAlerts: true,
useIndicator: true,
});
});
});

View File

@@ -0,0 +1,58 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { ChannelHeartbeatVisibilityConfig } from "../config/types.channels.js";
import type { DeliverableMessageChannel } from "../utils/message-channel.js";
export type ResolvedHeartbeatVisibility = {
showOk: boolean;
showAlerts: boolean;
useIndicator: boolean;
};
const DEFAULT_VISIBILITY: ResolvedHeartbeatVisibility = {
showOk: false, // Silent by default
showAlerts: true, // Show content messages
useIndicator: true, // Emit indicator events
};
export function resolveHeartbeatVisibility(params: {
cfg: ClawdbotConfig;
channel: DeliverableMessageChannel;
accountId?: string;
}): ResolvedHeartbeatVisibility {
const { cfg, channel, accountId } = params;
// Layer 1: Global channel defaults
const channelDefaults = cfg.channels?.defaults?.heartbeat;
// Layer 2: Per-channel config (at channel root level)
const channelCfg = cfg.channels?.[channel] as
| {
heartbeat?: ChannelHeartbeatVisibilityConfig;
accounts?: Record<string, { heartbeat?: ChannelHeartbeatVisibilityConfig }>;
}
| undefined;
const perChannel = channelCfg?.heartbeat;
// Layer 3: Per-account config (most specific)
const accountCfg = accountId ? channelCfg?.accounts?.[accountId] : undefined;
const perAccount = accountCfg?.heartbeat;
// Precedence: per-account > per-channel > channel-defaults > global defaults
return {
showOk:
perAccount?.showOk ??
perChannel?.showOk ??
channelDefaults?.showOk ??
DEFAULT_VISIBILITY.showOk,
showAlerts:
perAccount?.showAlerts ??
perChannel?.showAlerts ??
channelDefaults?.showAlerts ??
DEFAULT_VISIBILITY.showAlerts,
useIndicator:
perAccount?.useIndicator ??
perChannel?.useIndicator ??
channelDefaults?.useIndicator ??
DEFAULT_VISIBILITY.useIndicator,
};
}