diff --git a/src/infra/heartbeat-runner.test.ts b/src/infra/heartbeat-runner.test.ts index 24f882e94..a33d4a43d 100644 --- a/src/infra/heartbeat-runner.test.ts +++ b/src/infra/heartbeat-runner.test.ts @@ -321,6 +321,75 @@ describe("runHeartbeatOnce", () => { } }); + 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 { + await fs.writeFile( + storePath, + JSON.stringify( + { + main: { + sessionId: "sid", + updatedAt: Date.now(), + lastProvider: "whatsapp", + lastTo: "+1555", + }, + }, + null, + 2, + ), + ); + + const cfg: ClawdbotConfig = { + agents: { + defaults: { + heartbeat: { + every: "5m", + target: "whatsapp", + to: "+1555", + includeReasoning: true, + }, + }, + }, + whatsapp: { allowFrom: ["*"] }, + session: { store: storePath }, + }; + + replySpy.mockResolvedValue([ + { text: "Reasoning:\nBecause 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:\nBecause 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( diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index bbf86f902..b8ace042e 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -262,6 +262,11 @@ export async function runHeartbeatOnce(opts: { const replyPayload = resolveHeartbeatReplyPayload(replyResult); const includeReasoning = cfg.agents?.defaults?.heartbeat?.includeReasoning === true; + const reasoningPayloads = includeReasoning + ? resolveHeartbeatReasoningPayloads(replyResult).filter( + (payload) => payload !== replyPayload, + ) + : []; if ( !replyPayload || @@ -291,7 +296,8 @@ export async function runHeartbeatOnce(opts: { ).responsePrefix, ackMaxChars, ); - if (normalized.shouldSkip && !normalized.hasMedia) { + const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia; + if (shouldSkipMain && reasoningPayloads.length === 0) { await restoreHeartbeatUpdatedAt({ storePath, sessionKey, @@ -309,18 +315,18 @@ export async function runHeartbeatOnce(opts: { const mediaUrls = replyPayload.mediaUrls ?? (replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []); - - const reasoningPayloads = includeReasoning - ? resolveHeartbeatReasoningPayloads(replyResult).filter( - (payload) => payload !== replyPayload, - ) - : []; + const previewText = shouldSkipMain + ? reasoningPayloads + .map((payload) => payload.text) + .filter((text): text is string => Boolean(text?.trim())) + .join("\n") + : normalized.text; if (delivery.provider === "none" || !delivery.to) { emitHeartbeatEvent({ status: "skipped", reason: delivery.reason ?? "no-target", - preview: normalized.text?.slice(0, 200), + preview: previewText?.slice(0, 200), durationMs: Date.now() - startedAt, hasMedia: mediaUrls.length > 0, }); @@ -333,7 +339,7 @@ export async function runHeartbeatOnce(opts: { emitHeartbeatEvent({ status: "skipped", reason: readiness.reason, - preview: normalized.text?.slice(0, 200), + preview: previewText?.slice(0, 200), durationMs: Date.now() - startedAt, hasMedia: mediaUrls.length > 0, }); @@ -350,10 +356,14 @@ export async function runHeartbeatOnce(opts: { to: delivery.to, payloads: [ ...reasoningPayloads, - { - text: normalized.text, - mediaUrls, - }, + ...(shouldSkipMain + ? [] + : [ + { + text: normalized.text, + mediaUrls, + }, + ]), ], deps: opts.deps, }); @@ -361,7 +371,7 @@ export async function runHeartbeatOnce(opts: { emitHeartbeatEvent({ status: "sent", to: delivery.to, - preview: normalized.text?.slice(0, 200), + preview: previewText?.slice(0, 200), durationMs: Date.now() - startedAt, hasMedia: mediaUrls.length > 0, });