feat(heartbeat): skip API calls when HEARTBEAT.md is effectively empty (#1535)
* feat: skip heartbeat API calls when HEARTBEAT.md is effectively empty - Added isHeartbeatContentEffectivelyEmpty() to detect files with only headers/comments - Modified runHeartbeatOnce() to check HEARTBEAT.md content before polling the LLM - Returns early with 'empty-heartbeat-file' reason when no actionable tasks exist - Preserves existing behavior when file is missing (lets LLM decide) - Added comprehensive test coverage for empty file detection - Saves API calls/costs when heartbeat file has no meaningful content * chore: update HEARTBEAT.md template to be effectively empty by default Changed instruction text to comment format so new workspaces benefit from heartbeat optimization immediately. Users still get clear guidance on usage. * fix: only treat markdown headers (# followed by space) as comments, not #TODO etc * refactor: simplify regex per code review suggestion * docs: clarify heartbeat empty file behavior (#1535) (thanks @JustYannicc) --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "./heartbeat.js";
|
||||
import {
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
isHeartbeatContentEffectivelyEmpty,
|
||||
stripHeartbeatToken,
|
||||
} from "./heartbeat.js";
|
||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
|
||||
describe("stripHeartbeatToken", () => {
|
||||
@@ -105,3 +109,76 @@ describe("stripHeartbeatToken", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isHeartbeatContentEffectivelyEmpty", () => {
|
||||
it("returns false for undefined/null (missing file should not skip)", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty(undefined)).toBe(false);
|
||||
expect(isHeartbeatContentEffectivelyEmpty(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for empty string", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for whitespace only", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty(" ")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("\n\n\n")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty(" \n \n ")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("\t\t")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for header-only content", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for comments only", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# Header\n# Another comment")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("## Subheader\n### Another")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for default template content (header + comment)", () => {
|
||||
const defaultTemplate = `# HEARTBEAT.md
|
||||
|
||||
Keep this file empty unless you want a tiny checklist. Keep it small.
|
||||
`;
|
||||
// Note: The template has actual text content, so it's NOT effectively empty
|
||||
expect(isHeartbeatContentEffectivelyEmpty(defaultTemplate)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for header with only empty lines", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n\n\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when actionable content exists", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("- Check email")).toBe(false);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n- Task 1")).toBe(false);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("Remind me to call mom")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for content with tasks after header", () => {
|
||||
const content = `# HEARTBEAT.md
|
||||
|
||||
- Task 1
|
||||
- Task 2
|
||||
`;
|
||||
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for mixed content with non-comment text", () => {
|
||||
const content = `# HEARTBEAT.md
|
||||
## Tasks
|
||||
Check the server logs
|
||||
`;
|
||||
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats markdown headers as comments (effectively empty)", () => {
|
||||
const content = `# HEARTBEAT.md
|
||||
## Section 1
|
||||
### Subsection
|
||||
`;
|
||||
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,38 @@ export const HEARTBEAT_PROMPT =
|
||||
export const DEFAULT_HEARTBEAT_EVERY = "30m";
|
||||
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
|
||||
|
||||
/**
|
||||
* Check if HEARTBEAT.md content is "effectively empty" - meaning it has no actionable tasks.
|
||||
* This allows skipping heartbeat API calls when no tasks are configured.
|
||||
*
|
||||
* A file is considered effectively empty if it contains only:
|
||||
* - Whitespace
|
||||
* - Comment lines (lines starting with #)
|
||||
* - Empty lines
|
||||
*
|
||||
* Note: A missing file returns false (not effectively empty) so the LLM can still
|
||||
* decide what to do. This function is only for when the file exists but has no content.
|
||||
*/
|
||||
export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | null): boolean {
|
||||
if (content === undefined || content === null) return false;
|
||||
if (typeof content !== "string") return false;
|
||||
|
||||
const lines = content.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Skip empty lines
|
||||
if (!trimmed) continue;
|
||||
// Skip markdown header lines (# followed by space or EOL, ## etc)
|
||||
// This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content
|
||||
// (Those aren't valid markdown headers - ATX headers require space after #)
|
||||
if (/^#+(\s|$)/.test(trimmed)) continue;
|
||||
// Found a non-empty, non-comment line - there's actionable content
|
||||
return false;
|
||||
}
|
||||
// All lines were either empty or comments
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveHeartbeatPrompt(raw?: string): string {
|
||||
const trimmed = typeof raw === "string" ? raw.trim() : "";
|
||||
return trimmed || HEARTBEAT_PROMPT;
|
||||
|
||||
Reference in New Issue
Block a user