diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index b356e17b5a5..5df817b53b4 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -1,17 +1,13 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; import * as replyModule from "../auto-reply/reply.js"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveMainSessionKey } from "../config/sessions.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createPluginRuntime } from "../plugins/runtime/index.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { runHeartbeatOnce } from "./heartbeat-runner.js"; -import { seedSessionStore } from "./heartbeat-runner.test-utils.js"; +import { seedMainSessionStore, withTempHeartbeatSandbox } from "./heartbeat-runner.test-utils.js"; import { enqueueSystemEvent, resetSystemEventsForTest } from "./system-events.js"; // Avoid pulling optional runtime deps during isolated runs. @@ -32,18 +28,6 @@ afterEach(() => { }); describe("Ghost reminder bug (issue #13317)", () => { - const withTempDir = async ( - prefix: string, - run: (tmpDir: string) => Promise, - ): Promise => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - try { - return await run(tmpDir); - } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); - } - }; - const createHeartbeatDeps = (replyText: string) => { const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", @@ -55,14 +39,14 @@ describe("Ghost reminder bug (issue #13317)", () => { return { sendTelegram, getReplySpy }; }; - const createConfig = async ( - tmpDir: string, - ): Promise<{ cfg: OpenClawConfig; sessionKey: string }> => { - const storePath = path.join(tmpDir, "sessions.json"); + const createConfig = async (params: { + tmpDir: string; + storePath: string; + }): Promise<{ cfg: OpenClawConfig; sessionKey: string }> => { const cfg: OpenClawConfig = { agents: { defaults: { - workspace: tmpDir, + workspace: params.tmpDir, heartbeat: { every: "5m", target: "telegram", @@ -70,11 +54,9 @@ describe("Ghost reminder bug (issue #13317)", () => { }, }, channels: { telegram: { allowFrom: ["*"] } }, - session: { store: storePath }, + session: { store: params.storePath }, }; - const sessionKey = resolveMainSessionKey(cfg); - - await seedSessionStore(storePath, sessionKey, { + const sessionKey = await seedMainSessionStore(params.storePath, cfg, { lastChannel: "telegram", lastProvider: "telegram", lastTo: "155462274", @@ -84,14 +66,13 @@ describe("Ghost reminder bug (issue #13317)", () => { }; const expectCronEventPrompt = ( - getReplySpy: { mock: { calls: unknown[][] } }, - reminderText: string, - ) => { - expect(getReplySpy).toHaveBeenCalledTimes(1); - const calledCtx = (getReplySpy.mock.calls[0]?.[0] ?? null) as { + calledCtx: { Provider?: string; Body?: string; - } | null; + } | null, + reminderText: string, + ) => { + expect(calledCtx).not.toBeNull(); expect(calledCtx?.Provider).toBe("cron-event"); expect(calledCtx?.Body).toContain("scheduled reminder has been triggered"); expect(calledCtx?.Body).toContain(reminderText); @@ -105,63 +86,73 @@ describe("Ghost reminder bug (issue #13317)", () => { ): Promise<{ result: Awaited>; sendTelegram: ReturnType; - getReplySpy: ReturnType; + calledCtx: { Provider?: string; Body?: string } | null; }> => { - return await withTempDir(tmpPrefix, async (tmpDir) => { - const { sendTelegram, getReplySpy } = createHeartbeatDeps("Relay this reminder now"); - const { cfg, sessionKey } = await createConfig(tmpDir); - enqueue(sessionKey); - const result = await runHeartbeatOnce({ - cfg, - agentId: "main", - reason: "cron:reminder-job", - deps: { - sendTelegram, - }, - }); - return { result, sendTelegram, getReplySpy }; - }); + return withTempHeartbeatSandbox( + async ({ tmpDir, storePath }) => { + const { sendTelegram, getReplySpy } = createHeartbeatDeps("Relay this reminder now"); + const { cfg, sessionKey } = await createConfig({ tmpDir, storePath }); + enqueue(sessionKey); + const result = await runHeartbeatOnce({ + cfg, + agentId: "main", + reason: "cron:reminder-job", + deps: { + sendTelegram, + }, + }); + const calledCtx = (getReplySpy.mock.calls[0]?.[0] ?? null) as { + Provider?: string; + Body?: string; + } | null; + return { result, sendTelegram, calledCtx }; + }, + { prefix: tmpPrefix }, + ); }; it("does not use CRON_EVENT_PROMPT when only a HEARTBEAT_OK event is present", async () => { - await withTempDir("openclaw-ghost-", async (tmpDir) => { - const { sendTelegram, getReplySpy } = createHeartbeatDeps("Heartbeat check-in"); - const { cfg } = await createConfig(tmpDir); - enqueueSystemEvent("HEARTBEAT_OK", { sessionKey: resolveMainSessionKey(cfg) }); + await withTempHeartbeatSandbox( + async ({ tmpDir, storePath }) => { + const { sendTelegram, getReplySpy } = createHeartbeatDeps("Heartbeat check-in"); + const { cfg, sessionKey } = await createConfig({ tmpDir, storePath }); + enqueueSystemEvent("HEARTBEAT_OK", { sessionKey }); - const result = await runHeartbeatOnce({ - cfg, - agentId: "main", - reason: "cron:test-job", - deps: { - sendTelegram, - }, - }); + const result = await runHeartbeatOnce({ + cfg, + agentId: "main", + reason: "cron:test-job", + deps: { + sendTelegram, + }, + }); - expect(result.status).toBe("ran"); - expect(getReplySpy).toHaveBeenCalledTimes(1); - const calledCtx = getReplySpy.mock.calls[0]?.[0]; - expect(calledCtx?.Provider).toBe("heartbeat"); - expect(calledCtx?.Body).not.toContain("scheduled reminder has been triggered"); - expect(calledCtx?.Body).not.toContain("relay this reminder"); - expect(sendTelegram).toHaveBeenCalled(); - }); + expect(result.status).toBe("ran"); + expect(getReplySpy).toHaveBeenCalledTimes(1); + const calledCtx = getReplySpy.mock.calls[0]?.[0]; + expect(calledCtx?.Provider).toBe("heartbeat"); + expect(calledCtx?.Body).not.toContain("scheduled reminder has been triggered"); + expect(calledCtx?.Body).not.toContain("relay this reminder"); + expect(sendTelegram).toHaveBeenCalled(); + }, + { prefix: "openclaw-ghost-" }, + ); }); it("uses CRON_EVENT_PROMPT when an actionable cron event exists", async () => { - const { result, sendTelegram, getReplySpy } = await runCronReminderCase( + const { result, sendTelegram, calledCtx } = await runCronReminderCase( "openclaw-cron-", (sessionKey) => { enqueueSystemEvent("Reminder: Check Base Scout results", { sessionKey }); }, ); expect(result.status).toBe("ran"); - expectCronEventPrompt(getReplySpy, "Reminder: Check Base Scout results"); + expectCronEventPrompt(calledCtx, "Reminder: Check Base Scout results"); expect(sendTelegram).toHaveBeenCalled(); }); it("uses CRON_EVENT_PROMPT when cron events are mixed with heartbeat noise", async () => { - const { result, sendTelegram, getReplySpy } = await runCronReminderCase( + const { result, sendTelegram, calledCtx } = await runCronReminderCase( "openclaw-cron-mixed-", (sessionKey) => { enqueueSystemEvent("HEARTBEAT_OK", { sessionKey }); @@ -169,37 +160,39 @@ describe("Ghost reminder bug (issue #13317)", () => { }, ); expect(result.status).toBe("ran"); - expectCronEventPrompt(getReplySpy, "Reminder: Check Base Scout results"); + expectCronEventPrompt(calledCtx, "Reminder: Check Base Scout results"); expect(sendTelegram).toHaveBeenCalled(); }); it("uses CRON_EVENT_PROMPT for tagged cron events on interval wake", async () => { - await withTempDir("openclaw-cron-interval-", async (tmpDir) => { - await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), "- Check status\n", "utf-8"); - const { sendTelegram, getReplySpy } = createHeartbeatDeps("Relay this cron update now"); - const { cfg, sessionKey } = await createConfig(tmpDir); - enqueueSystemEvent("Cron: QMD maintenance completed", { - sessionKey, - contextKey: "cron:qmd-maintenance", - }); + await withTempHeartbeatSandbox( + async ({ tmpDir, storePath }) => { + const { sendTelegram, getReplySpy } = createHeartbeatDeps("Relay this cron update now"); + const { cfg, sessionKey } = await createConfig({ tmpDir, storePath }); + enqueueSystemEvent("Cron: QMD maintenance completed", { + sessionKey, + contextKey: "cron:qmd-maintenance", + }); - const result = await runHeartbeatOnce({ - cfg, - agentId: "main", - reason: "interval", - deps: { - sendTelegram, - }, - }); + const result = await runHeartbeatOnce({ + cfg, + agentId: "main", + reason: "interval", + deps: { + sendTelegram, + }, + }); - expect(result.status).toBe("ran"); - expect(getReplySpy).toHaveBeenCalledTimes(1); - const calledCtx = getReplySpy.mock.calls[0]?.[0]; - expect(calledCtx?.Provider).toBe("cron-event"); - expect(calledCtx?.Body).toContain("scheduled reminder has been triggered"); - expect(calledCtx?.Body).toContain("Cron: QMD maintenance completed"); - expect(calledCtx?.Body).not.toContain("Read HEARTBEAT.md"); - expect(sendTelegram).toHaveBeenCalled(); - }); + expect(result.status).toBe("ran"); + expect(getReplySpy).toHaveBeenCalledTimes(1); + const calledCtx = getReplySpy.mock.calls[0]?.[0]; + expect(calledCtx?.Provider).toBe("cron-event"); + expect(calledCtx?.Body).toContain("scheduled reminder has been triggered"); + expect(calledCtx?.Body).toContain("Cron: QMD maintenance completed"); + expect(calledCtx?.Body).not.toContain("Read HEARTBEAT.md"); + expect(sendTelegram).toHaveBeenCalled(); + }, + { prefix: "openclaw-cron-interval-" }, + ); }); });