Compare commits

...

3 Commits

3 changed files with 75 additions and 35 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,20 @@ function resolveHeartbeatRunPrompt(params: {
: hasCronEvents
? buildCronEventPrompt(cronEvents, { deliverToUser: params.canRelayToUser })
: resolveHeartbeatPrompt(params.cfg, params.heartbeat);
const prompt = appendHeartbeatWorkspacePathHint(basePrompt, params.workspaceDir);
const configuredPromptRaw =
params.heartbeat?.prompt ?? params.cfg.agents?.defaults?.heartbeat?.prompt;
const effectivePrompt = resolveHeartbeatPromptText(configuredPromptRaw);
const defaultPrompt = resolveHeartbeatPromptText(undefined);
const isUsingDefaultPrompt = effectivePrompt === defaultPrompt;
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.
// Also: treat empty-string overrides as a request to fall back to the default.
shouldAppend: isUsingDefaultPrompt && !hasExecCompletion && !hasCronEvents,
});
return { prompt, hasExecCompletion, hasCronEvents };
}

View File

@ -1,8 +1,6 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { withEnv } from "../test-utils/env.js";
async function importFreshPluginTestModules() {
@ -1354,37 +1352,25 @@ describe("loadOpenClawPlugins", () => {
};`,
});
const loaderModuleUrl = pathToFileURL(
path.join(process.cwd(), "src", "plugins", "loader.ts"),
).href;
const script = `
import { loadOpenClawPlugins } from ${JSON.stringify(loaderModuleUrl)};
const registry = loadOpenClawPlugins({
cache: false,
workspaceDir: ${JSON.stringify(plugin.dir)},
config: {
plugins: {
load: { paths: [${JSON.stringify(plugin.file)}] },
allow: ["legacy-root-import"],
},
},
});
const record = registry.plugins.find((entry) => entry.id === "legacy-root-import");
if (!record || record.status !== "loaded") {
console.error(record?.error ?? "legacy-root-import missing");
process.exit(1);
}
`;
execFileSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], {
cwd: process.cwd(),
env: {
...process.env,
const registry = withEnv(
{
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
encoding: "utf-8",
stdio: "pipe",
});
() =>
loadOpenClawPlugins({
cache: false,
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["legacy-root-import"],
},
},
}),
);
const record = registry.plugins.find((entry) => entry.id === "legacy-root-import");
expect(record?.status).toBe("loaded");
});
it("prefers dist plugin-sdk alias when loader runs from dist", () => {