diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index 916385441..4c93ee04b 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -377,4 +377,180 @@ describe("runCronIsolatedAgentTurn", () => { ); }); }); + + it("skips delivery when response is exactly HEARTBEAT_OK", async () => { + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn().mockResolvedValue({ + messageId: "t1", + chatId: "123", + }), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "HEARTBEAT_OK" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + deliver: true, + channel: "telegram", + to: "123", + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + // Job still succeeds, but no delivery happens. + expect(res.status).toBe("ok"); + expect(res.summary).toBe("HEARTBEAT_OK"); + expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); + }); + }); + + it("skips delivery when response has HEARTBEAT_OK with short padding", async () => { + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn().mockResolvedValue({ + messageId: "w1", + chatId: "+1234", + }), + sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + // Short junk around HEARTBEAT_OK (<=30 chars) should still skip delivery. + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "HEARTBEAT_OK 🦞" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath, { whatsapp: { allowFrom: ["+1234"] } }), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + deliver: true, + channel: "whatsapp", + to: "+1234", + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); + }); + }); + + it("delivers when response has HEARTBEAT_OK but also substantial content", async () => { + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn().mockResolvedValue({ + messageId: "t1", + chatId: "123", + }), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + // Long content after HEARTBEAT_OK should still be delivered. + const longContent = `Important alert: ${"a".repeat(50)}`; + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: `HEARTBEAT_OK ${longContent}` }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + deliver: true, + channel: "telegram", + to: "123", + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + expect(deps.sendMessageTelegram).toHaveBeenCalled(); + }); + }); + + it("delivers when response has HEARTBEAT_OK but includes media", async () => { + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn().mockResolvedValue({ + messageId: "t1", + chatId: "123", + }), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + // Media should still be delivered even if text is just HEARTBEAT_OK. + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [ + { text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }, + ], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + deliver: true, + channel: "telegram", + to: "123", + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + expect(deps.sendMessageTelegram).toHaveBeenCalledWith( + "123", + "HEARTBEAT_OK", + expect.objectContaining({ mediaUrl: "https://example.com/img.png" }), + ); + }); + }); }); diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 66b26b390..da5a95c81 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -18,6 +18,7 @@ import { ensureAgentWorkspace, } from "../agents/workspace.js"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; +import { stripHeartbeatToken } from "../auto-reply/heartbeat.js"; import { normalizeThinkLevel } from "../auto-reply/thinking.js"; import type { CliDeps } from "../cli/deps.js"; import type { ClawdbotConfig } from "../config/config.js"; @@ -57,6 +58,25 @@ function pickSummaryFromPayloads( return undefined; } +/** + * Check if all payloads are just heartbeat ack responses (HEARTBEAT_OK). + * Returns true if delivery should be skipped because there's no real content. + */ +function isHeartbeatOnlyResponse( + payloads: Array<{ text?: string; mediaUrl?: string; mediaUrls?: string[] }>, +) { + if (payloads.length === 0) return true; + return payloads.every((payload) => { + // If there's media, we should deliver regardless of text content. + const hasMedia = + (payload.mediaUrls?.length ?? 0) > 0 || Boolean(payload.mediaUrl); + if (hasMedia) return false; + // Use heartbeat mode to check if text is just HEARTBEAT_OK or short ack. + const result = stripHeartbeatToken(payload.text, { mode: "heartbeat" }); + return result.shouldSkip; + }); +} + function resolveDeliveryTarget( cfg: ClawdbotConfig, jobPayload: { @@ -343,7 +363,12 @@ export async function runCronIsolatedAgentTurn(params: { const summary = pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText); - if (delivery) { + // Skip delivery for heartbeat-only responses (HEARTBEAT_OK with no real content). + // This allows cron jobs to silently ack when nothing to report but still deliver + // actual content when there is something to say. + const skipHeartbeatDelivery = delivery && isHeartbeatOnlyResponse(payloads); + + if (delivery && !skipHeartbeatDelivery) { if (resolvedDelivery.channel === "whatsapp") { if (!resolvedDelivery.to) { if (!bestEffortDeliver)