diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 2ac6a8be0f3..2d02550eba3 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -1018,6 +1018,7 @@ describe("runHeartbeatOnce", () => { reason?: "interval" | "wake"; queueCronEvent?: boolean; replyText?: string; + cfgOverrides?: Partial; }) { 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; diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index c3c58d34c1e..8bac3aadf8d 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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 }; }