diff --git a/src/infra/heartbeat-active-hours.test.ts b/src/infra/heartbeat-active-hours.test.ts new file mode 100644 index 0000000000..e3bce7f5bd --- /dev/null +++ b/src/infra/heartbeat-active-hours.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { isWithinActiveHours } from "./heartbeat-active-hours.js"; + +function cfgWithUserTimezone(userTimezone = "UTC"): OpenClawConfig { + return { + agents: { + defaults: { + userTimezone, + }, + }, + }; +} + +function heartbeatWindow(start: string, end: string, timezone: string) { + return { + activeHours: { + start, + end, + timezone, + }, + }; +} + +describe("isWithinActiveHours", () => { + it("returns true when activeHours is not configured", () => { + expect( + isWithinActiveHours(cfgWithUserTimezone("UTC"), undefined, Date.UTC(2025, 0, 1, 3)), + ).toBe(true); + }); + + it("returns true when activeHours start/end are invalid", () => { + const cfg = cfgWithUserTimezone("UTC"); + expect( + isWithinActiveHours(cfg, heartbeatWindow("bad", "10:00", "UTC"), Date.UTC(2025, 0, 1, 9)), + ).toBe(true); + expect( + isWithinActiveHours(cfg, heartbeatWindow("08:00", "24:30", "UTC"), Date.UTC(2025, 0, 1, 9)), + ).toBe(true); + }); + + it("returns true when activeHours start equals end", () => { + const cfg = cfgWithUserTimezone("UTC"); + expect( + isWithinActiveHours( + cfg, + heartbeatWindow("08:00", "08:00", "UTC"), + Date.UTC(2025, 0, 1, 12, 0, 0), + ), + ).toBe(true); + }); + + it("respects user timezone windows for normal ranges", () => { + const cfg = cfgWithUserTimezone("UTC"); + const heartbeat = heartbeatWindow("08:00", "24:00", "user"); + + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 7, 0, 0))).toBe(false); + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 8, 0, 0))).toBe(true); + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 23, 59, 0))).toBe(true); + }); + + it("supports overnight ranges", () => { + const cfg = cfgWithUserTimezone("UTC"); + const heartbeat = heartbeatWindow("22:00", "06:00", "UTC"); + + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 23, 0, 0))).toBe(true); + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 5, 30, 0))).toBe(true); + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 12, 0, 0))).toBe(false); + }); + + it("respects explicit non-user timezones", () => { + const cfg = cfgWithUserTimezone("UTC"); + const heartbeat = heartbeatWindow("09:00", "17:00", "America/New_York"); + + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 15, 0, 0))).toBe(true); + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 23, 30, 0))).toBe(false); + }); + + it("falls back to user timezone when activeHours timezone is invalid", () => { + const cfg = cfgWithUserTimezone("UTC"); + const heartbeat = heartbeatWindow("08:00", "10:00", "Mars/Olympus"); + + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 9, 0, 0))).toBe(true); + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 11, 0, 0))).toBe(false); + }); +}); diff --git a/src/infra/heartbeat-runner.model-override.test.ts b/src/infra/heartbeat-runner.model-override.test.ts index 0db3fc68e3..c3e393fd7d 100644 --- a/src/infra/heartbeat-runner.model-override.test.ts +++ b/src/infra/heartbeat-runner.model-override.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; @@ -17,6 +17,47 @@ import { runHeartbeatOnce } from "./heartbeat-runner.js"; // Avoid pulling optional runtime deps during isolated runs. vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); +type SeedSessionInput = { + lastChannel: string; + lastTo: string; + updatedAt?: number; +}; + +async function withHeartbeatFixture( + run: (ctx: { + tmpDir: string; + storePath: string; + seedSession: (sessionKey: string, input: SeedSessionInput) => Promise; + }) => Promise, +) { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-model-")); + const storePath = path.join(tmpDir, "sessions.json"); + + const seedSession = async (sessionKey: string, input: SeedSessionInput) => { + await fs.writeFile( + storePath, + JSON.stringify( + { + [sessionKey]: { + sessionId: "sid", + updatedAt: input.updatedAt ?? Date.now(), + lastChannel: input.lastChannel, + lastTo: input.lastTo, + }, + }, + null, + 2, + ), + ); + }; + + try { + await run({ tmpDir, storePath, seedSession }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + beforeEach(() => { const runtime = createPluginRuntime(); setTelegramRuntime(runtime); @@ -29,12 +70,13 @@ beforeEach(() => { ); }); +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("runHeartbeatOnce – heartbeat model override", () => { it("passes heartbeatModelOverride from defaults heartbeat config", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-model-")); - const storePath = path.join(tmpDir, "sessions.json"); - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); - try { + await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => { const cfg: OpenClawConfig = { agents: { defaults: { @@ -50,23 +92,9 @@ describe("runHeartbeatOnce – heartbeat model override", () => { session: { store: storePath }, }; const sessionKey = resolveMainSessionKey(cfg); + await seedSession(sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" }); - await fs.writeFile( - storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId: "sid", - updatedAt: Date.now(), - lastChannel: "whatsapp", - lastTo: "+1555", - }, - }, - null, - 2, - ), - ); - + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); await runHeartbeatOnce({ @@ -85,17 +113,11 @@ describe("runHeartbeatOnce – heartbeat model override", () => { }), cfg, ); - } finally { - replySpy.mockRestore(); - await fs.rm(tmpDir, { recursive: true, force: true }); - } + }); }); it("passes per-agent heartbeat model override (merged with defaults)", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-model-")); - const storePath = path.join(tmpDir, "sessions.json"); - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); - try { + await withHeartbeatFixture(async ({ storePath, seedSession }) => { const cfg: OpenClawConfig = { agents: { defaults: { @@ -120,23 +142,9 @@ describe("runHeartbeatOnce – heartbeat model override", () => { session: { store: storePath }, }; const sessionKey = resolveAgentMainSessionKey({ cfg, agentId: "ops" }); + await seedSession(sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" }); - await fs.writeFile( - storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId: "sid", - updatedAt: Date.now(), - lastChannel: "whatsapp", - lastTo: "+1555", - }, - }, - null, - 2, - ), - ); - + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); await runHeartbeatOnce({ @@ -148,7 +156,6 @@ describe("runHeartbeatOnce – heartbeat model override", () => { }, }); - // Per-agent model should override the defaults model. expect(replySpy).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ @@ -157,17 +164,11 @@ describe("runHeartbeatOnce – heartbeat model override", () => { }), cfg, ); - } finally { - replySpy.mockRestore(); - await fs.rm(tmpDir, { recursive: true, force: true }); - } + }); }); it("does not pass heartbeatModelOverride when no heartbeat model is configured", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-model-")); - const storePath = path.join(tmpDir, "sessions.json"); - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); - try { + await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => { const cfg: OpenClawConfig = { agents: { defaults: { @@ -182,23 +183,9 @@ describe("runHeartbeatOnce – heartbeat model override", () => { session: { store: storePath }, }; const sessionKey = resolveMainSessionKey(cfg); + await seedSession(sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" }); - await fs.writeFile( - storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId: "sid", - updatedAt: Date.now(), - lastChannel: "whatsapp", - lastTo: "+1555", - }, - }, - null, - 2, - ), - ); - + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); await runHeartbeatOnce({ @@ -213,17 +200,11 @@ describe("runHeartbeatOnce – heartbeat model override", () => { const replyOpts = replySpy.mock.calls[0]?.[1]; expect(replyOpts).toStrictEqual({ isHeartbeat: true }); expect(replyOpts).not.toHaveProperty("heartbeatModelOverride"); - } finally { - replySpy.mockRestore(); - await fs.rm(tmpDir, { recursive: true, force: true }); - } + }); }); it("trims heartbeat model override before passing it downstream", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-model-")); - const storePath = path.join(tmpDir, "sessions.json"); - const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); - try { + await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => { const cfg: OpenClawConfig = { agents: { defaults: { @@ -239,23 +220,9 @@ describe("runHeartbeatOnce – heartbeat model override", () => { session: { store: storePath }, }; const sessionKey = resolveMainSessionKey(cfg); + await seedSession(sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" }); - await fs.writeFile( - storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId: "sid", - updatedAt: Date.now(), - lastChannel: "whatsapp", - lastTo: "+1555", - }, - }, - null, - 2, - ), - ); - + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); await runHeartbeatOnce({ @@ -274,9 +241,6 @@ describe("runHeartbeatOnce – heartbeat model override", () => { }), cfg, ); - } finally { - replySpy.mockRestore(); - await fs.rm(tmpDir, { recursive: true, force: true }); - } + }); }); });