





















1+// Covers isolated heartbeat outbound session routing and base-session bookkeeping.
2+import fs from "node:fs/promises";
3+import path from "node:path";
4+import { afterEach, describe, expect, it, vi } from "vitest";
5+import type { OpenClawConfig } from "../config/config.js";
6+import { resolveMainSessionKey } from "../config/sessions.js";
7+import { runHeartbeatOnce } from "./heartbeat-runner.js";
8+import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
9+import { seedSessionStore, withTempHeartbeatSandbox } from "./heartbeat-runner.test-utils.js";
10+11+const deliverOutboundPayloadsInternal = vi.hoisted(() =>
12+vi.fn().mockResolvedValue([{ channel: "whatsapp", messageId: "msg-1" }]),
13+);
14+15+vi.mock("./outbound/deliver.js", () => ({
16+deliverOutboundPayloads: deliverOutboundPayloadsInternal,
17+ deliverOutboundPayloadsInternal,
18+}));
19+20+installHeartbeatRunnerTestRuntime();
21+22+afterEach(() => {
23+deliverOutboundPayloadsInternal.mockClear();
24+});
25+26+type DeliveryRequest = {
27+channel?: string;
28+to?: string;
29+session?: {
30+key?: string;
31+policyKey?: string;
32+};
33+};
34+35+function latestDeliveryRequest(): DeliveryRequest {
36+const [request] = deliverOutboundPayloadsInternal.mock.calls.at(-1) ?? [];
37+if (!request || typeof request !== "object") {
38+throw new Error("expected heartbeat delivery request");
39+}
40+return request as DeliveryRequest;
41+}
42+43+function makeIsolatedLastTargetConfig(tmpDir: string, storePath: string): OpenClawConfig {
44+return {
45+agents: {
46+list: [{ id: "main", default: true }],
47+defaults: {
48+workspace: tmpDir,
49+heartbeat: {
50+every: "5m",
51+target: "last",
52+isolatedSession: true,
53+},
54+},
55+},
56+channels: { whatsapp: { allowFrom: ["*"] } },
57+session: { store: storePath },
58+};
59+}
60+61+describe("runHeartbeatOnce - isolated heartbeat outbound session mirror", () => {
62+it("uses the isolated run key for outbound delivery while base session owns target and state", async () => {
63+await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
64+const cfg = makeIsolatedLastTargetConfig(tmpDir, storePath);
65+const baseSessionKey = resolveMainSessionKey(cfg);
66+const isolatedSessionKey = `${baseSessionKey}:heartbeat`;
67+const nowMs = Date.now();
68+await fs.writeFile(
69+path.join(tmpDir, "HEARTBEAT.md"),
70+`tasks:
71+ - name: check-in
72+ interval: 5m
73+ prompt: "Check whether the user needs a status update."
74+`,
75+"utf-8",
76+);
77+await seedSessionStore(storePath, baseSessionKey, {
78+sessionId: "base-session",
79+updatedAt: nowMs - 1_000,
80+lastChannel: "whatsapp",
81+lastProvider: "whatsapp",
82+lastTo: "+15551234567",
83+});
84+replySpy.mockResolvedValueOnce({ text: "Status needs attention." });
85+86+const result = await runHeartbeatOnce({
87+ cfg,
88+deps: {
89+getReplyFromConfig: replySpy,
90+getQueueSize: () => 0,
91+nowMs: () => nowMs,
92+},
93+});
94+95+expect(result.status).toBe("ran");
96+expect(replySpy.mock.calls[0]?.[0]).toMatchObject({
97+SessionKey: isolatedSessionKey,
98+});
99+const deliveryRequest = latestDeliveryRequest();
100+expect(deliveryRequest).toMatchObject({
101+channel: "whatsapp",
102+to: "+15551234567",
103+session: {
104+key: isolatedSessionKey,
105+policyKey: baseSessionKey,
106+},
107+});
108+109+const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
110+string,
111+{
112+heartbeatTaskState?: Record<string, number>;
113+lastHeartbeatText?: string;
114+lastHeartbeatSentAt?: number;
115+heartbeatIsolatedBaseSessionKey?: string;
116+}
117+>;
118+expect(store[baseSessionKey]).toMatchObject({
119+heartbeatTaskState: { "check-in": nowMs },
120+lastHeartbeatText: "Status needs attention.",
121+lastHeartbeatSentAt: nowMs,
122+});
123+expect(store[isolatedSessionKey]).toMatchObject({
124+heartbeatIsolatedBaseSessionKey: baseSessionKey,
125+});
126+expect(store[isolatedSessionKey]?.heartbeatTaskState).toBeUndefined();
127+expect(store[isolatedSessionKey]?.lastHeartbeatText).toBeUndefined();
128+});
129+});
130+131+it("keeps the base policy key when wake re-entry starts from the isolated key", async () => {
132+await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
133+const cfg = makeIsolatedLastTargetConfig(tmpDir, storePath);
134+const baseSessionKey = resolveMainSessionKey(cfg);
135+const isolatedSessionKey = `${baseSessionKey}:heartbeat`;
136+const nowMs = Date.now();
137+138+await fs.writeFile(
139+storePath,
140+JSON.stringify({
141+[isolatedSessionKey]: {
142+sessionId: "isolated-session",
143+updatedAt: nowMs - 1_000,
144+lastChannel: "whatsapp",
145+lastProvider: "whatsapp",
146+lastTo: "+15551234567",
147+heartbeatIsolatedBaseSessionKey: baseSessionKey,
148+},
149+}),
150+"utf-8",
151+);
152+replySpy.mockResolvedValueOnce({ text: "Wake result needs attention." });
153+154+const result = await runHeartbeatOnce({
155+ cfg,
156+sessionKey: isolatedSessionKey,
157+deps: {
158+getReplyFromConfig: replySpy,
159+getQueueSize: () => 0,
160+nowMs: () => nowMs,
161+},
162+});
163+164+expect(result.status).toBe("ran");
165+expect(replySpy.mock.calls[0]?.[0]).toMatchObject({
166+SessionKey: isolatedSessionKey,
167+});
168+expect(latestDeliveryRequest()).toMatchObject({
169+channel: "whatsapp",
170+to: "+15551234567",
171+session: {
172+key: isolatedSessionKey,
173+policyKey: baseSessionKey,
174+},
175+});
176+});
177+});
178+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。