* fix(security): properly test Windows ACL audit for config includes The test expected fs.config_include.perms_writable on Windows but chmod 0o644 has no effect on Windows ACLs. Use icacls to grant Everyone write access, which properly triggers the security check. Also stubs execIcacls to return proper ACL output so the audit can parse permissions without running actual icacls on the system. Adds cleanup via try/finally to remove temp directory containing world-writable test file. Fixes checks-windows CI failure. * test: isolate heartbeat runner tests from user workspace * docs: update changelog for #2403 --------- Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
1007 lines
29 KiB
TypeScript
1007 lines
29 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
|
|
import * as replyModule from "../auto-reply/reply.js";
|
|
import type { ClawdbotConfig } from "../config/config.js";
|
|
import {
|
|
resolveAgentIdFromSessionKey,
|
|
resolveAgentMainSessionKey,
|
|
resolveMainSessionKey,
|
|
resolveStorePath,
|
|
} from "../config/sessions.js";
|
|
import { buildAgentPeerSessionKey } from "../routing/session-key.js";
|
|
import {
|
|
isHeartbeatEnabledForAgent,
|
|
resolveHeartbeatIntervalMs,
|
|
resolveHeartbeatPrompt,
|
|
runHeartbeatOnce,
|
|
} from "./heartbeat-runner.js";
|
|
import { resolveHeartbeatDeliveryTarget } from "./outbound/targets.js";
|
|
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
|
import { createPluginRuntime } from "../plugins/runtime/index.js";
|
|
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
|
import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
|
|
import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js";
|
|
import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js";
|
|
import { setWhatsAppRuntime } from "../../extensions/whatsapp/src/runtime.js";
|
|
|
|
// Avoid pulling optional runtime deps during isolated runs.
|
|
vi.mock("jiti", () => ({ createJiti: () => () => ({}) }));
|
|
|
|
beforeEach(() => {
|
|
const runtime = createPluginRuntime();
|
|
setTelegramRuntime(runtime);
|
|
setWhatsAppRuntime(runtime);
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
|
|
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
|
|
]),
|
|
);
|
|
});
|
|
|
|
describe("resolveHeartbeatIntervalMs", () => {
|
|
it("returns default when unset", () => {
|
|
expect(resolveHeartbeatIntervalMs({})).toBe(30 * 60_000);
|
|
});
|
|
|
|
it("returns null when invalid or zero", () => {
|
|
expect(
|
|
resolveHeartbeatIntervalMs({
|
|
agents: { defaults: { heartbeat: { every: "0m" } } },
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
resolveHeartbeatIntervalMs({
|
|
agents: { defaults: { heartbeat: { every: "oops" } } },
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("parses duration strings with minute defaults", () => {
|
|
expect(
|
|
resolveHeartbeatIntervalMs({
|
|
agents: { defaults: { heartbeat: { every: "5m" } } },
|
|
}),
|
|
).toBe(5 * 60_000);
|
|
expect(
|
|
resolveHeartbeatIntervalMs({
|
|
agents: { defaults: { heartbeat: { every: "5" } } },
|
|
}),
|
|
).toBe(5 * 60_000);
|
|
expect(
|
|
resolveHeartbeatIntervalMs({
|
|
agents: { defaults: { heartbeat: { every: "2h" } } },
|
|
}),
|
|
).toBe(2 * 60 * 60_000);
|
|
});
|
|
|
|
it("uses explicit heartbeat overrides when provided", () => {
|
|
expect(
|
|
resolveHeartbeatIntervalMs(
|
|
{ agents: { defaults: { heartbeat: { every: "30m" } } } },
|
|
undefined,
|
|
{ every: "5m" },
|
|
),
|
|
).toBe(5 * 60_000);
|
|
});
|
|
});
|
|
|
|
describe("resolveHeartbeatPrompt", () => {
|
|
it("uses the default prompt when unset", () => {
|
|
expect(resolveHeartbeatPrompt({})).toBe(HEARTBEAT_PROMPT);
|
|
});
|
|
|
|
it("uses a trimmed override when configured", () => {
|
|
const cfg: ClawdbotConfig = {
|
|
agents: { defaults: { heartbeat: { prompt: " ping " } } },
|
|
};
|
|
expect(resolveHeartbeatPrompt(cfg)).toBe("ping");
|
|
});
|
|
});
|
|
|
|
describe("isHeartbeatEnabledForAgent", () => {
|
|
it("enables only explicit heartbeat agents when configured", () => {
|
|
const cfg: ClawdbotConfig = {
|
|
agents: {
|
|
defaults: { heartbeat: { every: "30m" } },
|
|
list: [{ id: "main" }, { id: "ops", heartbeat: { every: "1h" } }],
|
|
},
|
|
};
|
|
expect(isHeartbeatEnabledForAgent(cfg, "main")).toBe(false);
|
|
expect(isHeartbeatEnabledForAgent(cfg, "ops")).toBe(true);
|
|
});
|
|
|
|
it("falls back to default agent when no explicit heartbeat entries", () => {
|
|
const cfg: ClawdbotConfig = {
|
|
agents: {
|
|
defaults: { heartbeat: { every: "30m" } },
|
|
list: [{ id: "main" }, { id: "ops" }],
|
|
},
|
|
};
|
|
expect(isHeartbeatEnabledForAgent(cfg, "main")).toBe(true);
|
|
expect(isHeartbeatEnabledForAgent(cfg, "ops")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("resolveHeartbeatDeliveryTarget", () => {
|
|
const baseEntry = {
|
|
sessionId: "sid",
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
it("respects target none", () => {
|
|
const cfg: ClawdbotConfig = {
|
|
agents: { defaults: { heartbeat: { target: "none" } } },
|
|
};
|
|
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
|
|
channel: "none",
|
|
reason: "target-none",
|
|
accountId: undefined,
|
|
lastChannel: undefined,
|
|
lastAccountId: undefined,
|
|
});
|
|
});
|
|
|
|
it("uses last route by default", () => {
|
|
const cfg: ClawdbotConfig = {};
|
|
const entry = {
|
|
...baseEntry,
|
|
lastChannel: "whatsapp" as const,
|
|
lastTo: "+1555",
|
|
};
|
|
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
|
|
channel: "whatsapp",
|
|
to: "+1555",
|
|
accountId: undefined,
|
|
lastChannel: "whatsapp",
|
|
lastAccountId: undefined,
|
|
});
|
|
});
|
|
|
|
it("normalizes explicit WhatsApp targets when allowFrom is '*'", () => {
|
|
const cfg: ClawdbotConfig = {
|
|
agents: {
|
|
defaults: {
|
|
heartbeat: { target: "whatsapp", to: "whatsapp:(555) 123" },
|
|
},
|
|
},
|
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
};
|
|
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
|
|
channel: "whatsapp",
|
|
to: "+555123",
|
|
accountId: undefined,
|
|
lastChannel: undefined,
|
|
lastAccountId: undefined,
|
|
});
|
|
});
|
|
|
|
it("skips when last route is webchat", () => {
|
|
const cfg: ClawdbotConfig = {};
|
|
const entry = {
|
|
...baseEntry,
|
|
lastChannel: "webchat" as const,
|
|
lastTo: "web",
|
|
};
|
|
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
|
|
channel: "none",
|
|
reason: "no-target",
|
|
accountId: undefined,
|
|
lastChannel: undefined,
|
|
lastAccountId: undefined,
|
|
});
|
|
});
|
|
|
|
it("applies allowFrom fallback for WhatsApp targets", () => {
|
|
const cfg: ClawdbotConfig = {
|
|
agents: { defaults: { heartbeat: { target: "whatsapp", to: "+1999" } } },
|
|
channels: { whatsapp: { allowFrom: ["+1555", "+1666"] } },
|
|
};
|
|
const entry = {
|
|
...baseEntry,
|
|
lastChannel: "whatsapp" as const,
|
|
lastTo: "+1222",
|
|
};
|
|
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
|
|
channel: "whatsapp",
|
|
to: "+1555",
|
|
reason: "allowFrom-fallback",
|
|
accountId: undefined,
|
|
lastChannel: "whatsapp",
|
|
lastAccountId: undefined,
|
|
});
|
|
});
|
|
|
|
it("keeps WhatsApp group targets even with allowFrom set", () => {
|
|
const cfg: ClawdbotConfig = {
|
|
channels: { whatsapp: { allowFrom: ["+1555"] } },
|
|
};
|
|
const entry = {
|
|
...baseEntry,
|
|
lastChannel: "whatsapp" as const,
|
|
lastTo: "120363401234567890@g.us",
|
|
};
|
|
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
|
|
channel: "whatsapp",
|
|
to: "120363401234567890@g.us",
|
|
accountId: undefined,
|
|
lastChannel: "whatsapp",
|
|
lastAccountId: undefined,
|
|
});
|
|
});
|
|
|
|
it("normalizes prefixed WhatsApp group targets for heartbeat delivery", () => {
|
|
const cfg: ClawdbotConfig = {
|
|
channels: { whatsapp: { allowFrom: ["+1555"] } },
|
|
};
|
|
const entry = {
|
|
...baseEntry,
|
|
lastChannel: "whatsapp" as const,
|
|
lastTo: "whatsapp:120363401234567890@G.US",
|
|
};
|
|
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
|
|
channel: "whatsapp",
|
|
to: "120363401234567890@g.us",
|
|
accountId: undefined,
|
|
lastChannel: "whatsapp",
|
|
lastAccountId: undefined,
|
|
});
|
|
});
|
|
|
|
it("keeps explicit telegram targets", () => {
|
|
const cfg: ClawdbotConfig = {
|
|
agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } },
|
|
};
|
|
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
|
|
channel: "telegram",
|
|
to: "123",
|
|
accountId: undefined,
|
|
lastChannel: undefined,
|
|
lastAccountId: undefined,
|
|
});
|
|
});
|
|
|
|
it("prefers per-agent heartbeat overrides when provided", () => {
|
|
const cfg: ClawdbotConfig = {
|
|
agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } },
|
|
};
|
|
const heartbeat = { target: "whatsapp", to: "+1555" } as const;
|
|
expect(
|
|
resolveHeartbeatDeliveryTarget({
|
|
cfg,
|
|
entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "+1999" },
|
|
heartbeat,
|
|
}),
|
|
).toEqual({
|
|
channel: "whatsapp",
|
|
to: "+1555",
|
|
accountId: undefined,
|
|
lastChannel: "whatsapp",
|
|
lastAccountId: undefined,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("runHeartbeatOnce", () => {
|
|
it("skips when agent heartbeat is not enabled", async () => {
|
|
const cfg: ClawdbotConfig = {
|
|
agents: {
|
|
defaults: { heartbeat: { every: "30m" } },
|
|
list: [{ id: "main" }, { id: "ops", heartbeat: { every: "1h" } }],
|
|
},
|
|
};
|
|
|
|
const res = await runHeartbeatOnce({ cfg, agentId: "main" });
|
|
expect(res.status).toBe("skipped");
|
|
if (res.status === "skipped") {
|
|
expect(res.reason).toBe("disabled");
|
|
}
|
|
});
|
|
|
|
it("skips outside active hours", async () => {
|
|
const cfg: ClawdbotConfig = {
|
|
agents: {
|
|
defaults: {
|
|
userTimezone: "UTC",
|
|
heartbeat: {
|
|
every: "30m",
|
|
activeHours: { start: "08:00", end: "24:00", timezone: "user" },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const res = await runHeartbeatOnce({
|
|
cfg,
|
|
deps: { nowMs: () => Date.UTC(2025, 0, 1, 7, 0, 0) },
|
|
});
|
|
|
|
expect(res.status).toBe("skipped");
|
|
if (res.status === "skipped") {
|
|
expect(res.reason).toBe("quiet-hours");
|
|
}
|
|
});
|
|
|
|
it("uses the last non-empty payload for delivery", 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: ["*"] } },
|
|
session: { store: storePath },
|
|
};
|
|
const sessionKey = resolveMainSessionKey(cfg);
|
|
|
|
await fs.writeFile(
|
|
storePath,
|
|
JSON.stringify(
|
|
{
|
|
[sessionKey]: {
|
|
sessionId: "sid",
|
|
updatedAt: Date.now(),
|
|
lastChannel: "whatsapp",
|
|
lastTo: "+1555",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
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,
|
|
webAuthExists: async () => true,
|
|
hasActiveWebListener: () => true,
|
|
},
|
|
});
|
|
|
|
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
|
expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object));
|
|
} finally {
|
|
replySpy.mockRestore();
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("uses per-agent heartbeat overrides and session keys", 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: {
|
|
heartbeat: { every: "30m", prompt: "Default prompt" },
|
|
},
|
|
list: [
|
|
{ id: "main", default: true },
|
|
{
|
|
id: "ops",
|
|
heartbeat: { every: "5m", target: "whatsapp", prompt: "Ops check" },
|
|
},
|
|
],
|
|
},
|
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
session: { store: storePath },
|
|
};
|
|
const sessionKey = resolveAgentMainSessionKey({ cfg, agentId: "ops" });
|
|
|
|
await fs.writeFile(
|
|
storePath,
|
|
JSON.stringify(
|
|
{
|
|
[sessionKey]: {
|
|
sessionId: "sid",
|
|
updatedAt: Date.now(),
|
|
lastChannel: "whatsapp",
|
|
lastTo: "+1555",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
replySpy.mockResolvedValue([{ text: "Final alert" }]);
|
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
|
messageId: "m1",
|
|
toJid: "jid",
|
|
});
|
|
|
|
await runHeartbeatOnce({
|
|
cfg,
|
|
agentId: "ops",
|
|
deps: {
|
|
sendWhatsApp,
|
|
getQueueSize: () => 0,
|
|
nowMs: () => 0,
|
|
webAuthExists: async () => true,
|
|
hasActiveWebListener: () => true,
|
|
},
|
|
});
|
|
|
|
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
|
expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object));
|
|
expect(replySpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({ Body: "Ops check", SessionKey: sessionKey }),
|
|
{ isHeartbeat: true },
|
|
cfg,
|
|
);
|
|
} finally {
|
|
replySpy.mockRestore();
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("runs heartbeats in the explicit session key when configured", 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 groupId = "120363401234567890@g.us";
|
|
const cfg: ClawdbotConfig = {
|
|
agents: {
|
|
defaults: {
|
|
workspace: tmpDir,
|
|
heartbeat: {
|
|
every: "5m",
|
|
target: "last",
|
|
},
|
|
},
|
|
},
|
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
session: { store: storePath },
|
|
};
|
|
const mainSessionKey = resolveMainSessionKey(cfg);
|
|
const agentId = resolveAgentIdFromSessionKey(mainSessionKey);
|
|
const groupSessionKey = buildAgentPeerSessionKey({
|
|
agentId,
|
|
channel: "whatsapp",
|
|
peerKind: "group",
|
|
peerId: groupId,
|
|
});
|
|
if (cfg.agents?.defaults?.heartbeat) {
|
|
cfg.agents.defaults.heartbeat.session = groupSessionKey;
|
|
}
|
|
|
|
await fs.writeFile(
|
|
storePath,
|
|
JSON.stringify(
|
|
{
|
|
[mainSessionKey]: {
|
|
sessionId: "sid-main",
|
|
updatedAt: Date.now(),
|
|
lastChannel: "whatsapp",
|
|
lastTo: "+1555",
|
|
},
|
|
[groupSessionKey]: {
|
|
sessionId: "sid-group",
|
|
updatedAt: Date.now() + 10_000,
|
|
lastChannel: "whatsapp",
|
|
lastTo: groupId,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
replySpy.mockResolvedValue([{ text: "Group alert" }]);
|
|
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(groupId, "Group alert", expect.any(Object));
|
|
expect(replySpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({ SessionKey: groupSessionKey }),
|
|
{ isHeartbeat: true },
|
|
cfg,
|
|
);
|
|
} finally {
|
|
replySpy.mockRestore();
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("suppresses duplicate heartbeat payloads within 24h", 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: ["*"] } },
|
|
session: { store: storePath },
|
|
};
|
|
const sessionKey = resolveMainSessionKey(cfg);
|
|
|
|
await fs.writeFile(
|
|
storePath,
|
|
JSON.stringify(
|
|
{
|
|
[sessionKey]: {
|
|
sessionId: "sid",
|
|
updatedAt: Date.now(),
|
|
lastChannel: "whatsapp",
|
|
lastTo: "+1555",
|
|
lastHeartbeatText: "Final alert",
|
|
lastHeartbeatSentAt: 0,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
replySpy.mockResolvedValue([{ text: "Final alert" }]);
|
|
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
|
|
|
await runHeartbeatOnce({
|
|
cfg,
|
|
deps: {
|
|
sendWhatsApp,
|
|
getQueueSize: () => 0,
|
|
nowMs: () => 60_000,
|
|
webAuthExists: async () => true,
|
|
hasActiveWebListener: () => true,
|
|
},
|
|
});
|
|
|
|
expect(sendWhatsApp).toHaveBeenCalledTimes(0);
|
|
} finally {
|
|
replySpy.mockRestore();
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("can include reasoning payloads when enabled", 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",
|
|
includeReasoning: true,
|
|
},
|
|
},
|
|
},
|
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
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: "Reasoning:\n_Because it helps_" },
|
|
{ text: "Final alert" },
|
|
]);
|
|
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(2);
|
|
expect(sendWhatsApp).toHaveBeenNthCalledWith(
|
|
1,
|
|
"+1555",
|
|
"Reasoning:\n_Because it helps_",
|
|
expect.any(Object),
|
|
);
|
|
expect(sendWhatsApp).toHaveBeenNthCalledWith(2, "+1555", "Final alert", expect.any(Object));
|
|
} finally {
|
|
replySpy.mockRestore();
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("delivers reasoning even when the main heartbeat reply is HEARTBEAT_OK", 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",
|
|
includeReasoning: true,
|
|
},
|
|
},
|
|
},
|
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
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: "Reasoning:\n_Because it helps_" },
|
|
{ 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).toHaveBeenNthCalledWith(
|
|
1,
|
|
"+1555",
|
|
"Reasoning:\n_Because it helps_",
|
|
expect.any(Object),
|
|
);
|
|
} finally {
|
|
replySpy.mockRestore();
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("loads the default agent session from templated stores", async () => {
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
|
const storeTemplate = path.join(tmpDir, "agents", "{agentId}", "sessions.json");
|
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
|
try {
|
|
const cfg: ClawdbotConfig = {
|
|
agents: {
|
|
defaults: { workspace: tmpDir, heartbeat: { every: "5m" } },
|
|
list: [{ id: "work", default: true }],
|
|
},
|
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
session: { store: storeTemplate },
|
|
};
|
|
const sessionKey = resolveMainSessionKey(cfg);
|
|
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
|
const storePath = resolveStorePath(storeTemplate, { agentId });
|
|
|
|
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
|
await fs.writeFile(
|
|
storePath,
|
|
JSON.stringify(
|
|
{
|
|
[sessionKey]: {
|
|
sessionId: "sid",
|
|
updatedAt: Date.now(),
|
|
lastChannel: "whatsapp",
|
|
lastProvider: "whatsapp",
|
|
lastTo: "+1555",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
|
|
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",
|
|
"Hello from heartbeat",
|
|
expect.any(Object),
|
|
);
|
|
} finally {
|
|
replySpy.mockRestore();
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("skips heartbeat when HEARTBEAT.md is effectively empty (saves API calls)", async () => {
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
|
const storePath = path.join(tmpDir, "sessions.json");
|
|
const workspaceDir = path.join(tmpDir, "workspace");
|
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
|
try {
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
|
|
// Create effectively empty HEARTBEAT.md (only header and comments)
|
|
await fs.writeFile(
|
|
path.join(workspaceDir, "HEARTBEAT.md"),
|
|
"# HEARTBEAT.md\n\n## Tasks\n\n",
|
|
"utf-8",
|
|
);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
agents: {
|
|
defaults: {
|
|
workspace: workspaceDir,
|
|
heartbeat: { every: "5m", target: "whatsapp" },
|
|
},
|
|
},
|
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
session: { store: storePath },
|
|
};
|
|
const sessionKey = resolveMainSessionKey(cfg);
|
|
|
|
await fs.writeFile(
|
|
storePath,
|
|
JSON.stringify(
|
|
{
|
|
[sessionKey]: {
|
|
sessionId: "sid",
|
|
updatedAt: Date.now(),
|
|
lastChannel: "whatsapp",
|
|
lastTo: "+1555",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
|
messageId: "m1",
|
|
toJid: "jid",
|
|
});
|
|
|
|
const res = await runHeartbeatOnce({
|
|
cfg,
|
|
deps: {
|
|
sendWhatsApp,
|
|
getQueueSize: () => 0,
|
|
nowMs: () => 0,
|
|
webAuthExists: async () => true,
|
|
hasActiveWebListener: () => true,
|
|
},
|
|
});
|
|
|
|
// Should skip without making API call
|
|
expect(res.status).toBe("skipped");
|
|
if (res.status === "skipped") {
|
|
expect(res.reason).toBe("empty-heartbeat-file");
|
|
}
|
|
expect(replySpy).not.toHaveBeenCalled();
|
|
expect(sendWhatsApp).not.toHaveBeenCalled();
|
|
} finally {
|
|
replySpy.mockRestore();
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("runs heartbeat when HEARTBEAT.md has actionable content", async () => {
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
|
const storePath = path.join(tmpDir, "sessions.json");
|
|
const workspaceDir = path.join(tmpDir, "workspace");
|
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
|
try {
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
|
|
// Create HEARTBEAT.md with actionable content
|
|
await fs.writeFile(
|
|
path.join(workspaceDir, "HEARTBEAT.md"),
|
|
"# HEARTBEAT.md\n\n- Check server logs\n- Review pending PRs\n",
|
|
"utf-8",
|
|
);
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
agents: {
|
|
defaults: {
|
|
workspace: workspaceDir,
|
|
heartbeat: { every: "5m", target: "whatsapp" },
|
|
},
|
|
},
|
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
session: { store: storePath },
|
|
};
|
|
const sessionKey = resolveMainSessionKey(cfg);
|
|
|
|
await fs.writeFile(
|
|
storePath,
|
|
JSON.stringify(
|
|
{
|
|
[sessionKey]: {
|
|
sessionId: "sid",
|
|
updatedAt: Date.now(),
|
|
lastChannel: "whatsapp",
|
|
lastTo: "+1555",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
replySpy.mockResolvedValue({ text: "Checked logs and PRs" });
|
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
|
messageId: "m1",
|
|
toJid: "jid",
|
|
});
|
|
|
|
const res = await runHeartbeatOnce({
|
|
cfg,
|
|
deps: {
|
|
sendWhatsApp,
|
|
getQueueSize: () => 0,
|
|
nowMs: () => 0,
|
|
webAuthExists: async () => true,
|
|
hasActiveWebListener: () => true,
|
|
},
|
|
});
|
|
|
|
// Should run and make API call
|
|
expect(res.status).toBe("ran");
|
|
expect(replySpy).toHaveBeenCalled();
|
|
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
replySpy.mockRestore();
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("runs heartbeat when HEARTBEAT.md does not exist (lets LLM decide)", async () => {
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
|
const storePath = path.join(tmpDir, "sessions.json");
|
|
const workspaceDir = path.join(tmpDir, "workspace");
|
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
|
try {
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
// Don't create HEARTBEAT.md - it doesn't exist
|
|
|
|
const cfg: ClawdbotConfig = {
|
|
agents: {
|
|
defaults: {
|
|
workspace: workspaceDir,
|
|
heartbeat: { every: "5m", target: "whatsapp" },
|
|
},
|
|
},
|
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
session: { store: storePath },
|
|
};
|
|
const sessionKey = resolveMainSessionKey(cfg);
|
|
|
|
await fs.writeFile(
|
|
storePath,
|
|
JSON.stringify(
|
|
{
|
|
[sessionKey]: {
|
|
sessionId: "sid",
|
|
updatedAt: Date.now(),
|
|
lastChannel: "whatsapp",
|
|
lastTo: "+1555",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
|
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
|
messageId: "m1",
|
|
toJid: "jid",
|
|
});
|
|
|
|
const res = await runHeartbeatOnce({
|
|
cfg,
|
|
deps: {
|
|
sendWhatsApp,
|
|
getQueueSize: () => 0,
|
|
nowMs: () => 0,
|
|
webAuthExists: async () => true,
|
|
hasActiveWebListener: () => true,
|
|
},
|
|
});
|
|
|
|
// Should run (not skip) - let LLM decide since file doesn't exist
|
|
expect(res.status).toBe("ran");
|
|
expect(replySpy).toHaveBeenCalled();
|
|
} finally {
|
|
replySpy.mockRestore();
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|