diff --git a/src/cron/isolated-agent/helpers.test.ts b/src/cron/isolated-agent/helpers.test.ts index 31e533170..365125764 100644 --- a/src/cron/isolated-agent/helpers.test.ts +++ b/src/cron/isolated-agent/helpers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + isHeartbeatOnlyResponse, pickLastDeliverablePayload, pickLastNonEmptyTextFromPayloads, pickSummaryFromPayloads, @@ -84,3 +85,65 @@ describe("pickLastDeliverablePayload", () => { expect(pickLastDeliverablePayload([normal, error])).toBe(normal); }); }); + +describe("isHeartbeatOnlyResponse", () => { + const ACK_MAX = 300; + + it("returns true for empty payloads", () => { + expect(isHeartbeatOnlyResponse([], ACK_MAX)).toBe(true); + }); + + it("returns true for a single HEARTBEAT_OK payload", () => { + expect(isHeartbeatOnlyResponse([{ text: "HEARTBEAT_OK" }], ACK_MAX)).toBe(true); + }); + + it("returns false for a single non-heartbeat payload", () => { + expect(isHeartbeatOnlyResponse([{ text: "Something important happened" }], ACK_MAX)).toBe( + false, + ); + }); + + it("returns true when multiple payloads include narration followed by HEARTBEAT_OK", () => { + // Agent narrates its work then signals nothing needs attention. + expect( + isHeartbeatOnlyResponse( + [ + { text: "It's 12:49 AM — quiet hours. Let me run the checks quickly." }, + { text: "Emails: Just 2 calendar invites. Not urgent." }, + { text: "HEARTBEAT_OK" }, + ], + ACK_MAX, + ), + ).toBe(true); + }); + + it("returns false when media is present even with HEARTBEAT_OK text", () => { + expect( + isHeartbeatOnlyResponse( + [{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }], + ACK_MAX, + ), + ).toBe(false); + }); + + it("returns false when media is in a different payload than HEARTBEAT_OK", () => { + expect( + isHeartbeatOnlyResponse( + [ + { text: "HEARTBEAT_OK" }, + { text: "Here's an image", mediaUrl: "https://example.com/img.png" }, + ], + ACK_MAX, + ), + ).toBe(false); + }); + + it("returns false when no payload contains HEARTBEAT_OK", () => { + expect( + isHeartbeatOnlyResponse( + [{ text: "Checked emails — found 3 urgent messages from your manager." }], + ACK_MAX, + ), + ).toBe(false); + }); +}); diff --git a/src/cron/isolated-agent/helpers.ts b/src/cron/isolated-agent/helpers.ts index c74b65d1b..5e7f92990 100644 --- a/src/cron/isolated-agent/helpers.ts +++ b/src/cron/isolated-agent/helpers.ts @@ -94,13 +94,18 @@ export function isHeartbeatOnlyResponse(payloads: DeliveryPayload[], ackMaxChars 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. + // If any payload has media, deliver regardless — there's real content. + const hasAnyMedia = payloads.some( + (payload) => (payload.mediaUrls?.length ?? 0) > 0 || Boolean(payload.mediaUrl), + ); + if (hasAnyMedia) { + return false; + } + // An agent may emit multiple text payloads (narration, tool summaries) + // before a final HEARTBEAT_OK. If *any* payload is a heartbeat ack token, + // the agent is signaling "nothing needs attention" — the preceding text + // payloads are just internal narration and should not be delivered. + return payloads.some((payload) => { const result = stripHeartbeatToken(payload.text, { mode: "heartbeat", maxAckChars: ackMaxChars,