fix(heartbeat): respect custom heartbeat prompt

This commit is contained in:
vignesh07
2026-03-08 14:01:07 -07:00
parent 64dd23eade
commit 4b710d79fe
2 changed files with 54 additions and 4 deletions

View File

@@ -1018,6 +1018,7 @@ describe("runHeartbeatOnce", () => {
reason?: "interval" | "wake";
queueCronEvent?: boolean;
replyText?: string;
cfgOverrides?: Partial<OpenClawConfig>;
}) {
const tmpDir = await createCaseDir("openclaw-hb");
const storePath = path.join(tmpDir, "sessions.json");
@@ -1041,7 +1042,7 @@ describe("runHeartbeatOnce", () => {
await fs.mkdir(path.join(workspaceDir, "HEARTBEAT.md"), { recursive: true });
}
const cfg: OpenClawConfig = {
const cfgBase: OpenClawConfig = {
agents: {
defaults: {
workspace: workspaceDir,
@@ -1051,6 +1052,9 @@ describe("runHeartbeatOnce", () => {
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const cfg: OpenClawConfig = params.cfgOverrides
? { ...cfgBase, ...params.cfgOverrides }
: cfgBase;
const sessionKey = resolveMainSessionKey(cfg);
await fs.writeFile(
storePath,
@@ -1083,7 +1087,7 @@ describe("runHeartbeatOnce", () => {
return { res, replySpy, sendWhatsApp, workspaceDir };
}
it("adds explicit workspace HEARTBEAT.md path guidance to heartbeat prompts", async () => {
it("adds explicit workspace HEARTBEAT.md path guidance to default heartbeat prompts", async () => {
const { res, replySpy, sendWhatsApp, workspaceDir } = await runHeartbeatFileScenario({
fileState: "actionable",
reason: "interval",
@@ -1102,6 +1106,35 @@ describe("runHeartbeatOnce", () => {
}
});
it("does not mutate a custom heartbeat prompt", async () => {
const customPrompt =
"Read HEARTBEAT.md if it exists (workspace context). Use the system prompt only.";
const { res, replySpy } = await runHeartbeatFileScenario({
fileState: "actionable",
reason: "interval",
replyText: "Checked logs and PRs",
cfgOverrides: {
agents: {
defaults: {
heartbeat: {
prompt: customPrompt,
},
},
},
},
});
try {
expect(res.status).toBe("ran");
expect(replySpy).toHaveBeenCalledTimes(1);
const calledCtx = replySpy.mock.calls[0]?.[0] as { Body?: string };
expect(calledCtx.Body).toContain(customPrompt);
expect(calledCtx.Body).not.toContain("Do not read docs/heartbeat.md.");
expect(calledCtx.Body).not.toContain("use workspace file");
} finally {
replySpy.mockRestore();
}
});
it("applies HEARTBEAT.md gating rules across file states and triggers", async () => {
const cases: Array<{
name: string;

View File

@@ -560,7 +560,15 @@ type HeartbeatPromptResolution = {
hasCronEvents: boolean;
};
function appendHeartbeatWorkspacePathHint(prompt: string, workspaceDir: string): string {
function appendHeartbeatWorkspacePathHint(params: {
prompt: string;
workspaceDir: string;
shouldAppend: boolean;
}): string {
const { prompt, workspaceDir, shouldAppend } = params;
if (!shouldAppend) {
return prompt;
}
if (!/heartbeat\.md/i.test(prompt)) {
return prompt;
}
@@ -597,7 +605,16 @@ function resolveHeartbeatRunPrompt(params: {
: hasCronEvents
? buildCronEventPrompt(cronEvents, { deliverToUser: params.canRelayToUser })
: resolveHeartbeatPrompt(params.cfg, params.heartbeat);
const prompt = appendHeartbeatWorkspacePathHint(basePrompt, params.workspaceDir);
const hasExplicitPrompt = Boolean(
params.heartbeat?.prompt?.trim() || params.cfg.agents?.defaults?.heartbeat?.prompt?.trim(),
);
const prompt = appendHeartbeatWorkspacePathHint({
prompt: basePrompt,
workspaceDir: params.workspaceDir,
// Only append the hint when using the built-in default heartbeat prompt.
// If the user configured a custom prompt, do not mutate it.
shouldAppend: !hasExplicitPrompt && !hasExecCompletion && !hasCronEvents,
});
return { prompt, hasExecCompletion, hasCronEvents };
}