From a78dba43961c073105217b10cdeb34d3a7eec5e3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Thu, 2 Apr 2026 00:24:10 +0100 Subject: [PATCH] refactor: lazy load heartbeat reply runtime --- .../heartbeat-runner.ghost-reminder.test.ts | 8 +-- .../heartbeat-runner.model-override.test.ts | 31 ++++---- ...espects-ackmaxchars-heartbeat-acks.test.ts | 50 +++++++++---- ...tbeat-runner.returns-default-unset.test.ts | 72 ++++++++++--------- src/infra/heartbeat-runner.runtime.ts | 1 + ...ner.sender-prefers-delivery-target.test.ts | 1 + src/infra/heartbeat-runner.test-utils.ts | 9 ++- .../heartbeat-runner.transcript-prune.test.ts | 5 +- src/infra/heartbeat-runner.ts | 11 ++- 9 files changed, 118 insertions(+), 70 deletions(-) create mode 100644 src/infra/heartbeat-runner.runtime.ts diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index ed29185035f..8f4e35bf3f0 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -1,6 +1,5 @@ import fs from "node:fs/promises"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as replyModule from "../auto-reply/reply.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveMainSessionKey } from "../config/sessions.js"; import { runHeartbeatOnce } from "./heartbeat-runner.js"; @@ -27,9 +26,7 @@ describe("Ghost reminder bug (issue #13317)", () => { messageId: "m1", chatId: "155462274", }); - const getReplySpy = vi - .spyOn(replyModule, "getReplyFromConfig") - .mockResolvedValue({ text: replyText }); + const getReplySpy = vi.fn().mockResolvedValue({ text: replyText }); return { sendTelegram, getReplySpy }; }; @@ -117,6 +114,7 @@ describe("Ghost reminder bug (issue #13317)", () => { agentId: "main", reason: params.reason, deps: { + getReplyFromConfig: getReplySpy, telegram: sendTelegram, }, }); @@ -278,6 +276,7 @@ describe("Ghost reminder bug (issue #13317)", () => { agentId: "main", reason: "wake", deps: { + getReplyFromConfig: replySpy, telegram: sendTelegram, }, }); @@ -340,6 +339,7 @@ describe("Ghost reminder bug (issue #13317)", () => { agentId: "main", reason: "wake", deps: { + getReplyFromConfig: replySpy, telegram: sendTelegram, }, }); diff --git a/src/infra/heartbeat-runner.model-override.test.ts b/src/infra/heartbeat-runner.model-override.test.ts index 5ec85bda601..bac426cc12b 100644 --- a/src/infra/heartbeat-runner.model-override.test.ts +++ b/src/infra/heartbeat-runner.model-override.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import * as replyModule from "../auto-reply/reply.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentMainSessionKey, resolveMainSessionKey } from "../config/sessions.js"; import { runHeartbeatOnce } from "./heartbeat-runner.js"; @@ -19,11 +18,12 @@ async function withHeartbeatFixture( run: (ctx: { tmpDir: string; storePath: string; + replySpy: ReturnType; seedSession: (sessionKey: string, input: SeedSessionInput) => Promise; }) => Promise, ): Promise { return withTempHeartbeatSandbox( - async ({ tmpDir, storePath }) => { + async ({ tmpDir, storePath, replySpy }) => { const seedSession = async (sessionKey: string, input: SeedSessionInput) => { await seedSessionStore(storePath, sessionKey, { updatedAt: input.updatedAt, @@ -32,7 +32,7 @@ async function withHeartbeatFixture( lastTo: input.lastTo, }); }; - return run({ tmpDir, storePath, seedSession }); + return run({ tmpDir, storePath, replySpy, seedSession }); }, { prefix: "openclaw-hb-model-" }, ); @@ -47,27 +47,28 @@ describe("runHeartbeatOnce – heartbeat model override", () => { seedSession: (sessionKey: string, input: SeedSessionInput) => Promise; cfg: OpenClawConfig; sessionKey: string; + replySpy: ReturnType; agentId?: string; }) { await params.seedSession(params.sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" }); - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); - replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); + params.replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); await runHeartbeatOnce({ cfg: params.cfg, agentId: params.agentId, deps: { + getReplyFromConfig: params.replySpy, getQueueSize: () => 0, nowMs: () => 0, }, }); - expect(replySpy).toHaveBeenCalledTimes(1); + expect(params.replySpy).toHaveBeenCalledTimes(1); return { - ctx: replySpy.mock.calls[0]?.[0], - opts: replySpy.mock.calls[0]?.[1], - replySpy, + ctx: params.replySpy.mock.calls[0]?.[0], + opts: params.replySpy.mock.calls[0]?.[1], + replySpy: params.replySpy, }; } @@ -77,7 +78,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => { lightContext?: boolean; isolatedSession?: boolean; }) { - return withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => { + return withHeartbeatFixture(async ({ tmpDir, storePath, replySpy, seedSession }) => { const cfg: OpenClawConfig = { agents: { defaults: { @@ -100,6 +101,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => { seedSession, cfg, sessionKey, + replySpy, }); return result.opts; }); @@ -137,7 +139,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => { }); it("uses isolated session key when isolatedSession is enabled", async () => { - await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => { + await withHeartbeatFixture(async ({ tmpDir, storePath, replySpy, seedSession }) => { const cfg: OpenClawConfig = { agents: { defaults: { @@ -157,6 +159,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => { seedSession, cfg, sessionKey, + replySpy, }); // Isolated heartbeat runs use a dedicated session key with :heartbeat suffix @@ -165,7 +168,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => { }); it("uses main session key when isolatedSession is not set", async () => { - await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => { + await withHeartbeatFixture(async ({ tmpDir, storePath, replySpy, seedSession }) => { const cfg: OpenClawConfig = { agents: { defaults: { @@ -184,6 +187,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => { seedSession, cfg, sessionKey, + replySpy, }); expect(result.ctx?.SessionKey).toBe(sessionKey); @@ -191,7 +195,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => { }); it("passes per-agent heartbeat model override (merged with defaults)", async () => { - await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => { + await withHeartbeatFixture(async ({ tmpDir, storePath, replySpy, seedSession }) => { const cfg: OpenClawConfig = { agents: { defaults: { @@ -222,6 +226,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => { cfg, agentId: "ops", sessionKey, + replySpy, }); expect(result.replySpy).toHaveBeenCalledWith( diff --git a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts index 1cf436384ea..8b6f13abe5e 100644 --- a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts +++ b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts @@ -78,7 +78,7 @@ describe("runHeartbeatOnce ack handling", () => { async function runTelegramHeartbeatWithDefaults(params: { tmpDir: string; storePath: string; - replySpy: ReturnType; + replySpy: ReturnType; replyText: string; messages?: Record; telegramOverrides?: Record; @@ -108,7 +108,10 @@ describe("runHeartbeatOnce ack handling", () => { const sendTelegram = createMessageSendSpy(); await runHeartbeatOnce({ cfg, - deps: makeTelegramDeps({ sendTelegram }), + deps: { + ...makeTelegramDeps({ sendTelegram }), + getReplyFromConfig: params.replySpy, + }, }); return sendTelegram; } @@ -170,7 +173,10 @@ describe("runHeartbeatOnce ack handling", () => { await runHeartbeatOnce({ cfg, - deps: makeWhatsAppDeps({ sendWhatsApp }), + deps: { + ...makeWhatsAppDeps({ sendWhatsApp }), + getReplyFromConfig: replySpy, + }, }); expect(sendWhatsApp).toHaveBeenCalled(); @@ -196,7 +202,10 @@ describe("runHeartbeatOnce ack handling", () => { await runHeartbeatOnce({ cfg, - deps: makeWhatsAppDeps({ sendWhatsApp }), + deps: { + ...makeWhatsAppDeps({ sendWhatsApp }), + getReplyFromConfig: replySpy, + }, }); expect(sendWhatsApp).toHaveBeenCalledTimes(1); @@ -258,7 +267,10 @@ describe("runHeartbeatOnce ack handling", () => { const result = await runHeartbeatOnce({ cfg, - deps: makeWhatsAppDeps({ sendWhatsApp }), + deps: { + ...makeWhatsAppDeps({ sendWhatsApp }), + getReplyFromConfig: replySpy, + }, }); expect(replySpy).not.toHaveBeenCalled(); @@ -279,7 +291,10 @@ describe("runHeartbeatOnce ack handling", () => { await runHeartbeatOnce({ cfg, - deps: makeWhatsAppDeps({ sendWhatsApp }), + deps: { + ...makeWhatsAppDeps({ sendWhatsApp }), + getReplyFromConfig: replySpy, + }, }); expect(sendWhatsApp).not.toHaveBeenCalled(); @@ -317,7 +332,10 @@ describe("runHeartbeatOnce ack handling", () => { await runHeartbeatOnce({ cfg, - deps: makeWhatsAppDeps(), + deps: { + ...makeWhatsAppDeps(), + getReplyFromConfig: replySpy, + }, }); const finalStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< @@ -340,11 +358,14 @@ describe("runHeartbeatOnce ack handling", () => { const res = await runHeartbeatOnce({ cfg, - deps: makeWhatsAppDeps({ - sendWhatsApp, - webAuthExists: async () => false, - hasActiveWebListener: () => false, - }), + deps: { + ...makeWhatsAppDeps({ + sendWhatsApp, + webAuthExists: async () => false, + hasActiveWebListener: () => false, + }), + getReplyFromConfig: replySpy, + }, }); expect(res.status).toBe("skipped"); @@ -376,7 +397,10 @@ describe("runHeartbeatOnce ack handling", () => { await runHeartbeatOnce({ cfg, - deps: makeTelegramDeps({ sendTelegram }), + deps: { + ...makeTelegramDeps({ sendTelegram }), + getReplyFromConfig: replySpy, + }, }); expect(sendTelegram).toHaveBeenCalledTimes(1); diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 3282812a984..c4c3e972033 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -3,7 +3,6 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js"; -import * as replyModule from "../auto-reply/reply.js"; import type { ChannelOutboundAdapter } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { @@ -482,13 +481,17 @@ describe("runHeartbeatOnce", () => { text: string, opts?: unknown, ) => Promise<{ messageId: string; toJid: string }>, - nowMs = 0, + options?: { + nowMs?: number; + getReplyFromConfig?: HeartbeatDeps["getReplyFromConfig"]; + }, ): HeartbeatDeps => ({ whatsapp: sendWhatsApp, getQueueSize: () => 0, - nowMs: () => nowMs, + nowMs: () => options?.nowMs ?? 0, webAuthExists: async () => true, hasActiveWebListener: () => true, + ...(options?.getReplyFromConfig ? { getReplyFromConfig: options.getReplyFromConfig } : null), }); it("skips when agent heartbeat is not enabled", async () => { @@ -533,7 +536,7 @@ describe("runHeartbeatOnce", () => { it("uses the last non-empty payload for delivery", async () => { const tmpDir = await createCaseDir("hb-last-payload"); const storePath = path.join(tmpDir, "sessions.json"); - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + const replySpy = vi.fn(); try { const cfg: OpenClawConfig = { agents: { @@ -575,7 +578,7 @@ describe("runHeartbeatOnce", () => { await runHeartbeatOnce({ cfg, - deps: createHeartbeatDeps(sendWhatsApp), + deps: createHeartbeatDeps(sendWhatsApp, { getReplyFromConfig: replySpy }), }); expect(sendWhatsApp).toHaveBeenCalledTimes(1); @@ -585,14 +588,14 @@ describe("runHeartbeatOnce", () => { expect.any(Object), ); } finally { - replySpy.mockRestore(); + replySpy.mockReset(); } }); it("uses per-agent heartbeat overrides and session keys", async () => { const tmpDir = await createCaseDir("hb-agent-overrides"); const storePath = path.join(tmpDir, "sessions.json"); - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + const replySpy = vi.fn(); try { const cfg: OpenClawConfig = { agents: { @@ -640,7 +643,7 @@ describe("runHeartbeatOnce", () => { await runHeartbeatOnce({ cfg, agentId: "ops", - deps: createHeartbeatDeps(sendWhatsApp), + deps: createHeartbeatDeps(sendWhatsApp, { getReplyFromConfig: replySpy }), }); expect(sendWhatsApp).toHaveBeenCalledTimes(1); expect(sendWhatsApp).toHaveBeenCalledWith( @@ -662,14 +665,14 @@ describe("runHeartbeatOnce", () => { cfg, ); } finally { - replySpy.mockRestore(); + replySpy.mockReset(); } }); it("reuses non-default agent sessionFile from templated stores", async () => { const tmpDir = await createCaseDir("hb-templated-store"); const storeTemplate = path.join(tmpDir, "agents", "{agentId}", "sessions", "sessions.json"); - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + const replySpy = vi.fn(); const agentId = "ops"; try { const cfg: OpenClawConfig = { @@ -726,7 +729,7 @@ describe("runHeartbeatOnce", () => { const result = await runHeartbeatOnce({ cfg, agentId, - deps: createHeartbeatDeps(sendWhatsApp), + deps: createHeartbeatDeps(sendWhatsApp, { getReplyFromConfig: replySpy }), }); expect(result.status).toBe("ran"); @@ -747,7 +750,7 @@ describe("runHeartbeatOnce", () => { cfg, ); } finally { - replySpy.mockRestore(); + replySpy.mockReset(); } }); @@ -779,7 +782,7 @@ describe("runHeartbeatOnce", () => { ])( "resolves configured and forced session key overrides: $name", async ({ name, caseDir, peerKind, peerId, message, applyOverride, runOptions }) => { - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + const replySpy = vi.fn(); try { const tmpDir = await createCaseDir(caseDir); const storePath = path.join(tmpDir, "sessions.json"); @@ -839,7 +842,7 @@ describe("runHeartbeatOnce", () => { await runHeartbeatOnce({ cfg, ...runOptions({ sessionKey: overrideSessionKey }), - deps: createHeartbeatDeps(sendWhatsApp), + deps: createHeartbeatDeps(sendWhatsApp, { getReplyFromConfig: replySpy }), }); expect(sendWhatsApp, name).toHaveBeenCalledTimes(1); @@ -855,7 +858,7 @@ describe("runHeartbeatOnce", () => { cfg, ); } finally { - replySpy.mockRestore(); + replySpy.mockReset(); } }, ); @@ -863,7 +866,7 @@ describe("runHeartbeatOnce", () => { it("suppresses duplicate heartbeat payloads within 24h", async () => { const tmpDir = await createCaseDir("hb-dup-suppress"); const storePath = path.join(tmpDir, "sessions.json"); - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + const replySpy = vi.fn(); try { const cfg: OpenClawConfig = { agents: { @@ -904,12 +907,15 @@ describe("runHeartbeatOnce", () => { await runHeartbeatOnce({ cfg, - deps: createHeartbeatDeps(sendWhatsApp, 60_000), + deps: createHeartbeatDeps(sendWhatsApp, { + nowMs: 60_000, + getReplyFromConfig: replySpy, + }), }); expect(sendWhatsApp).toHaveBeenCalledTimes(0); } finally { - replySpy.mockRestore(); + replySpy.mockReset(); } }); @@ -936,7 +942,7 @@ describe("runHeartbeatOnce", () => { )( "handles reasoning payload delivery variants: $name", async ({ name, caseDir, replies, expectedTexts }) => { - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + const replySpy = vi.fn(); try { const tmpDir = await createCaseDir(caseDir); const storePath = path.join(tmpDir, "sessions.json"); @@ -983,7 +989,7 @@ describe("runHeartbeatOnce", () => { await runHeartbeatOnce({ cfg, - deps: createHeartbeatDeps(sendWhatsApp), + deps: createHeartbeatDeps(sendWhatsApp, { getReplyFromConfig: replySpy }), }); expect(sendWhatsApp, name).toHaveBeenCalledTimes(expectedTexts.length); @@ -996,7 +1002,7 @@ describe("runHeartbeatOnce", () => { ); } } finally { - replySpy.mockRestore(); + replySpy.mockReset(); } }, ); @@ -1004,7 +1010,7 @@ describe("runHeartbeatOnce", () => { it("loads the default agent session from templated stores", async () => { const tmpDir = await createCaseDir("openclaw-hb"); const storeTemplate = path.join(tmpDir, "agents", "{agentId}", "sessions.json"); - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + const replySpy = vi.fn(); try { const cfg: OpenClawConfig = { agents: { @@ -1048,7 +1054,7 @@ describe("runHeartbeatOnce", () => { await runHeartbeatOnce({ cfg, - deps: createHeartbeatDeps(sendWhatsApp), + deps: createHeartbeatDeps(sendWhatsApp, { getReplyFromConfig: replySpy }), }); expect(sendWhatsApp).toHaveBeenCalledTimes(1); @@ -1058,7 +1064,7 @@ describe("runHeartbeatOnce", () => { expect.any(Object), ); } finally { - replySpy.mockRestore(); + replySpy.mockReset(); } }); @@ -1121,7 +1127,7 @@ describe("runHeartbeatOnce", () => { }); } - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + const replySpy = vi.fn(); replySpy.mockResolvedValue({ text: params.replyText ?? "Checked logs and PRs" }); const sendWhatsApp = vi .fn< @@ -1131,7 +1137,7 @@ describe("runHeartbeatOnce", () => { const res = await runHeartbeatOnce({ cfg, reason: params.reason, - deps: createHeartbeatDeps(sendWhatsApp), + deps: createHeartbeatDeps(sendWhatsApp, { getReplyFromConfig: replySpy }), }); return { res, replySpy, sendWhatsApp, workspaceDir }; } @@ -1262,7 +1268,7 @@ describe("runHeartbeatOnce", () => { expect(calledCtx.Body, name).toContain("scheduled reminder has been triggered"); } } finally { - replySpy.mockRestore(); + replySpy.mockReset(); } } }); @@ -1297,7 +1303,7 @@ describe("runHeartbeatOnce", () => { contextKey: "cron:rotate-logs", }); - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + const replySpy = vi.fn(); replySpy.mockResolvedValue({ text: "Handled internally" }); const sendWhatsApp = vi .fn< @@ -1309,7 +1315,7 @@ describe("runHeartbeatOnce", () => { const res = await runHeartbeatOnce({ cfg, reason: "interval", - deps: createHeartbeatDeps(sendWhatsApp), + deps: createHeartbeatDeps(sendWhatsApp, { getReplyFromConfig: replySpy }), }); expect(res.status).toBe("ran"); expect(sendWhatsApp).toHaveBeenCalledTimes(0); @@ -1318,7 +1324,7 @@ describe("runHeartbeatOnce", () => { expect(calledCtx.Body).toContain("Handle this reminder internally"); expect(calledCtx.Body).not.toContain("Please relay this reminder to the user"); } finally { - replySpy.mockRestore(); + replySpy.mockReset(); } }); @@ -1352,7 +1358,7 @@ describe("runHeartbeatOnce", () => { contextKey: "exec:backup", }); - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + const replySpy = vi.fn(); replySpy.mockResolvedValue({ text: "Handled internally" }); const sendWhatsApp = vi .fn< @@ -1364,7 +1370,7 @@ describe("runHeartbeatOnce", () => { const res = await runHeartbeatOnce({ cfg, reason: "exec-event", - deps: createHeartbeatDeps(sendWhatsApp), + deps: createHeartbeatDeps(sendWhatsApp, { getReplyFromConfig: replySpy }), }); expect(res.status).toBe("ran"); expect(sendWhatsApp).toHaveBeenCalledTimes(0); @@ -1378,7 +1384,7 @@ describe("runHeartbeatOnce", () => { expect(calledCtx.Body).toContain("Handle the result internally"); expect(calledCtx.Body).not.toContain("Please relay the command output to the user"); } finally { - replySpy.mockRestore(); + replySpy.mockReset(); } }); }); diff --git a/src/infra/heartbeat-runner.runtime.ts b/src/infra/heartbeat-runner.runtime.ts new file mode 100644 index 00000000000..6cba74ded25 --- /dev/null +++ b/src/infra/heartbeat-runner.runtime.ts @@ -0,0 +1 @@ +export { getReplyFromConfig } from "../auto-reply/reply.js"; diff --git a/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts b/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts index 5683fa62f5d..7c8c6a2df5c 100644 --- a/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts +++ b/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts @@ -44,6 +44,7 @@ describe("runHeartbeatOnce", () => { await runHeartbeatOnce({ cfg, deps: { + getReplyFromConfig: replySpy, slack: sendSlack, getQueueSize: () => 0, nowMs: () => 0, diff --git a/src/infra/heartbeat-runner.test-utils.ts b/src/infra/heartbeat-runner.test-utils.ts index a3832ae0c6a..4bded21056d 100644 --- a/src/infra/heartbeat-runner.test-utils.ts +++ b/src/infra/heartbeat-runner.test-utils.ts @@ -2,7 +2,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { vi } from "vitest"; -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"; @@ -48,7 +47,7 @@ export async function withTempHeartbeatSandbox( fn: (ctx: { tmpDir: string; storePath: string; - replySpy: ReturnType; + replySpy: ReturnType; }) => Promise, options?: { prefix?: string; @@ -58,7 +57,7 @@ export async function withTempHeartbeatSandbox( const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), options?.prefix ?? "openclaw-hb-")); await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), "- Check status\n", "utf-8"); const storePath = path.join(tmpDir, "sessions.json"); - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + const replySpy = vi.fn().mockResolvedValue({ text: "ok" }); const previousEnv = new Map(); for (const envName of options?.unsetEnvVars ?? []) { previousEnv.set(envName, process.env[envName]); @@ -67,7 +66,7 @@ export async function withTempHeartbeatSandbox( try { return await fn({ tmpDir, storePath, replySpy }); } finally { - replySpy.mockRestore(); + replySpy.mockReset(); for (const [envName, previousValue] of previousEnv.entries()) { if (previousValue === undefined) { delete process.env[envName]; @@ -83,7 +82,7 @@ export async function withTempTelegramHeartbeatSandbox( fn: (ctx: { tmpDir: string; storePath: string; - replySpy: ReturnType; + replySpy: ReturnType; }) => Promise, options?: { prefix?: string; diff --git a/src/infra/heartbeat-runner.transcript-prune.test.ts b/src/infra/heartbeat-runner.transcript-prune.test.ts index 5639dee476f..264bc60e764 100644 --- a/src/infra/heartbeat-runner.transcript-prune.test.ts +++ b/src/infra/heartbeat-runner.transcript-prune.test.ts @@ -70,7 +70,10 @@ describe("heartbeat transcript pruning", () => { agentId: undefined, reason: "test", cfg, - deps: { sendTelegram: vi.fn() }, + deps: { + sendTelegram: vi.fn(), + getReplyFromConfig: replySpy, + }, }); const finalSize = (await fs.stat(transcriptPath)).size; diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 85b6b54d112..dc35b67a63b 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -19,7 +19,6 @@ import { resolveHeartbeatPrompt as resolveHeartbeatPromptText, stripHeartbeatToken, } from "../auto-reply/heartbeat.js"; -import { getReplyFromConfig } from "../auto-reply/reply.js"; import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { getChannelPlugin } from "../channels/plugins/index.js"; @@ -84,12 +83,20 @@ import { peekSystemEventEntries, resolveSystemEventDeliveryContext } from "./sys export type HeartbeatDeps = OutboundSendDeps & ChannelHeartbeatDeps & { + getReplyFromConfig?: typeof import("./heartbeat-runner.runtime.js").getReplyFromConfig; runtime?: RuntimeEnv; getQueueSize?: (lane?: string) => number; nowMs?: () => number; }; const log = createSubsystemLogger("gateway/heartbeat"); +let heartbeatRunnerRuntimePromise: Promise | null = + null; + +function loadHeartbeatRunnerRuntime() { + heartbeatRunnerRuntimePromise ??= import("./heartbeat-runner.runtime.js"); + return heartbeatRunnerRuntimePromise; +} export { areHeartbeatsEnabled, setHeartbeatsEnabled }; export { @@ -730,6 +737,8 @@ export async function runHeartbeatOnce(opts: { bootstrapContextMode, } : { isHeartbeat: true, suppressToolErrorWarnings, bootstrapContextMode }; + const getReplyFromConfig = + opts.deps?.getReplyFromConfig ?? (await loadHeartbeatRunnerRuntime()).getReplyFromConfig; const replyResult = await getReplyFromConfig(ctx, replyOpts, cfg); const replyPayload = resolveHeartbeatReplyPayload(replyResult); const includeReasoning = heartbeat?.includeReasoning === true;