feat(heartbeat): add directPolicy and restore default direct delivery

This commit is contained in:
Peter Steinberger
2026-02-26 03:56:40 +01:00
parent ee594e2fdb
commit 8a006a3260
14 changed files with 149 additions and 16 deletions

View File

@@ -234,4 +234,32 @@ describe("config plugin validation", () => {
});
}
});
it("accepts heartbeat directPolicy enum values", async () => {
const home = await createCaseHome();
const res = validateInHome(home, {
agents: {
defaults: { heartbeat: { target: "last", directPolicy: "block" } },
list: [{ id: "pi", heartbeat: { directPolicy: "allow" } }],
},
});
expect(res.ok).toBe(true);
});
it("rejects invalid heartbeat directPolicy values", async () => {
const home = await createCaseHome();
const res = validateInHome(home, {
agents: {
defaults: { heartbeat: { directPolicy: "maybe" } },
list: [{ id: "pi" }],
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
const hasIssue = res.issues.some(
(issue) => issue.path === "agents.defaults.heartbeat.directPolicy",
);
expect(hasIssue).toBe(true);
}
});
});

View File

@@ -1238,6 +1238,10 @@ export const FIELD_HELP: Record<string, string> = {
"Shows degraded/error heartbeat alerts when true so operator channels surface problems promptly. Keep enabled in production so broken channel states are visible.",
"channels.defaults.heartbeat.useIndicator":
"Enables concise indicator-style heartbeat rendering instead of verbose status text where supported. Use indicator mode for dense dashboards with many active channels.",
"agents.defaults.heartbeat.directPolicy":
'Controls whether heartbeat delivery may target direct/DM chats: "allow" (default) permits DM delivery and "block" suppresses direct-target sends.',
"agents.list.*.heartbeat.directPolicy":
'Per-agent override for heartbeat direct/DM delivery policy; use "block" for agents that should only send heartbeat alerts to non-DM destinations.',
"channels.telegram.configWrites":
"Allow Telegram to write config in response to channel events/commands (default: true).",
"channels.telegram.botToken":

View File

@@ -402,6 +402,8 @@ export const FIELD_LABELS: Record<string, string> = {
"Compaction Memory Flush Soft Threshold",
"agents.defaults.compaction.memoryFlush.prompt": "Compaction Memory Flush Prompt",
"agents.defaults.compaction.memoryFlush.systemPrompt": "Compaction Memory Flush System Prompt",
"agents.defaults.heartbeat.directPolicy": "Heartbeat Direct Policy",
"agents.list.*.heartbeat.directPolicy": "Heartbeat Direct Policy",
"agents.defaults.heartbeat.suppressToolErrorWarnings": "Heartbeat Suppress Tool Error Warnings",
"agents.defaults.sandbox.browser.network": "Sandbox Browser Network",
"agents.defaults.sandbox.browser.cdpSourceRange": "Sandbox Browser CDP Source Port Range",

View File

@@ -213,6 +213,8 @@ export type AgentDefaultsConfig = {
session?: string;
/** Delivery target ("last", "none", or a channel id). */
target?: "last" | "none" | ChannelId;
/** Direct/DM delivery policy. Default: "allow". */
directPolicy?: "allow" | "block";
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). Supports :topic:NNN suffix for Telegram topics. */
to?: string;
/** Optional account id for multi-account channels. */

View File

@@ -26,6 +26,7 @@ export const HeartbeatSchema = z
session: z.string().optional(),
includeReasoning: z.boolean().optional(),
target: z.string().optional(),
directPolicy: z.union([z.literal("allow"), z.literal("block")]).optional(),
to: z.string().optional(),
accountId: z.string().optional(),
prompt: z.string().optional(),

View File

@@ -325,6 +325,30 @@ describe("resolveHeartbeatDeliveryTarget", () => {
lastAccountId: undefined,
},
},
{
name: "allow direct target by default",
cfg: { agents: { defaults: { heartbeat: { target: "last" } } } },
entry: { ...baseEntry, lastChannel: "telegram", lastTo: "5232990709" },
expected: {
channel: "telegram",
to: "5232990709",
accountId: undefined,
lastChannel: "telegram",
lastAccountId: undefined,
},
},
{
name: "block direct target when directPolicy is block",
cfg: { agents: { defaults: { heartbeat: { target: "last", directPolicy: "block" } } } },
entry: { ...baseEntry, lastChannel: "telegram", lastTo: "5232990709" },
expected: {
channel: "none",
reason: "dm-blocked",
accountId: undefined,
lastChannel: "telegram",
lastAccountId: undefined,
},
},
];
for (const testCase of cases) {
expect(

View File

@@ -301,7 +301,7 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.to).toBe("63448508");
});
it("blocks heartbeat delivery to Slack DMs and avoids inherited threadId", () => {
it("allows heartbeat delivery to Slack DMs and avoids inherited threadId by default", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
@@ -317,12 +317,34 @@ describe("resolveSessionDeliveryTarget", () => {
},
});
expect(resolved.channel).toBe("slack");
expect(resolved.to).toBe("user:U123");
expect(resolved.threadId).toBeUndefined();
});
it("blocks heartbeat delivery to Slack DMs when directPolicy is block", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
entry: {
sessionId: "sess-heartbeat-outbound",
updatedAt: 1,
lastChannel: "slack",
lastTo: "user:U123",
lastThreadId: "1739142736.000100",
},
heartbeat: {
target: "last",
directPolicy: "block",
},
});
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("dm-blocked");
expect(resolved.threadId).toBeUndefined();
});
it("blocks heartbeat delivery to Discord DMs", () => {
it("allows heartbeat delivery to Discord DMs by default", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
@@ -337,11 +359,11 @@ describe("resolveSessionDeliveryTarget", () => {
},
});
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("dm-blocked");
expect(resolved.channel).toBe("discord");
expect(resolved.to).toBe("user:12345");
});
it("blocks heartbeat delivery to Telegram direct chats", () => {
it("allows heartbeat delivery to Telegram direct chats by default", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
@@ -356,6 +378,26 @@ describe("resolveSessionDeliveryTarget", () => {
},
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("5232990709");
});
it("blocks heartbeat delivery to Telegram direct chats when directPolicy is block", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
entry: {
sessionId: "sess-heartbeat-telegram-direct",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "5232990709",
},
heartbeat: {
target: "last",
directPolicy: "block",
},
});
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("dm-blocked");
});
@@ -379,7 +421,7 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.to).toBe("-1001234567890");
});
it("blocks heartbeat delivery to WhatsApp direct chats", () => {
it("allows heartbeat delivery to WhatsApp direct chats by default", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
@@ -394,8 +436,8 @@ describe("resolveSessionDeliveryTarget", () => {
},
});
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("dm-blocked");
expect(resolved.channel).toBe("whatsapp");
expect(resolved.to).toBe("+15551234567");
});
it("keeps heartbeat delivery to WhatsApp groups", () => {
@@ -417,7 +459,7 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.to).toBe("120363140186826074@g.us");
});
it("uses session chatType hint when target parser cannot classify", () => {
it("uses session chatType hint when target parser cannot classify and allows direct by default", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
@@ -433,6 +475,27 @@ describe("resolveSessionDeliveryTarget", () => {
},
});
expect(resolved.channel).toBe("imessage");
expect(resolved.to).toBe("chat-guid-unknown-shape");
});
it("blocks session chatType direct hints when directPolicy is block", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
entry: {
sessionId: "sess-heartbeat-imessage-direct",
updatedAt: 1,
lastChannel: "imessage",
lastTo: "chat-guid-unknown-shape",
chatType: "direct",
},
heartbeat: {
target: "last",
directPolicy: "block",
},
});
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("dm-blocked");
});

View File

@@ -330,7 +330,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
to: resolved.to,
sessionChatType: sessionChatTypeHint,
});
if (deliveryChatType === "direct") {
if (deliveryChatType === "direct" && heartbeat?.directPolicy === "block") {
return buildNoHeartbeatDeliveryTarget({
reason: "dm-blocked",
accountId: effectiveAccountId,