From c592f395dfdd44b22278e3d1054447e92d44b592 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 01:14:06 +0000 Subject: [PATCH] test: update health/status and legacy migration coverage --- src/commands/gateway-status.test.ts | 4 +- src/commands/health.command.coverage.test.ts | 26 ++++ src/commands/health.snapshot.test.ts | 32 ++++- src/commands/health.test.ts | 118 ++++++++++++++++-- src/commands/status.test.ts | 73 +++++++++-- ...etection.accepts-imessage-dmpolicy.test.ts | 37 ++++++ ...tbeat-runner.returns-default-unset.test.ts | 40 ++++++ 7 files changed, 305 insertions(+), 25 deletions(-) diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 61c9e3b6f..4e9fb16aa 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -30,7 +30,7 @@ const probeGateway = vi.fn(async ({ url }: { url: string }) => { close: null, health: { ok: true }, status: { - linkProvider: { + linkChannel: { id: "whatsapp", label: "WhatsApp", linked: false, @@ -60,7 +60,7 @@ const probeGateway = vi.fn(async ({ url }: { url: string }) => { close: null, health: { ok: true }, status: { - linkProvider: { + linkChannel: { id: "whatsapp", label: "WhatsApp", linked: true, diff --git a/src/commands/health.command.coverage.test.ts b/src/commands/health.command.coverage.test.ts index 00c691ad4..3dde904cc 100644 --- a/src/commands/health.command.coverage.test.ts +++ b/src/commands/health.command.coverage.test.ts @@ -35,10 +35,12 @@ describe("healthCommand (coverage)", () => { durationMs: 5, channels: { whatsapp: { + accountId: "default", linked: true, authAgeMs: 5 * 60_000, }, telegram: { + accountId: "default", configured: true, probe: { ok: true, @@ -48,6 +50,7 @@ describe("healthCommand (coverage)", () => { }, }, discord: { + accountId: "default", configured: false, }, }, @@ -58,6 +61,29 @@ describe("healthCommand (coverage)", () => { discord: "Discord", }, heartbeatSeconds: 60, + defaultAgentId: "main", + agents: [ + { + agentId: "main", + isDefault: true, + heartbeat: { + enabled: true, + every: "1m", + everyMs: 60_000, + prompt: "hi", + target: "last", + ackMaxChars: 160, + }, + sessions: { + path: "/tmp/sessions.json", + count: 2, + recent: [ + { key: "main", updatedAt: Date.now() - 60_000, age: 60_000 }, + { key: "foo", updatedAt: null, age: null }, + ], + }, + }, + ], sessions: { path: "/tmp/sessions.json", count: 2, diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index 3cf6087a6..436e3a275 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -30,10 +30,6 @@ vi.mock("../web/auth-store.js", () => ({ logWebSelfId: vi.fn(), })); -vi.mock("../web/reconnect.js", () => ({ - resolveHeartbeatSeconds: vi.fn(() => 60), -})); - describe("getHealthSnapshot", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -229,4 +225,32 @@ describe("getHealthSnapshot", () => { expect(telegram.probe?.ok).toBe(false); expect(telegram.probe?.error).toMatch(/network down/i); }); + + it("disables heartbeat for agents without heartbeat blocks", async () => { + testConfig = { + agents: { + defaults: { + heartbeat: { + every: "30m", + target: "last", + }, + }, + list: [ + { id: "main", default: true }, + { id: "ops", heartbeat: { every: "1h", target: "whatsapp" } }, + ], + }, + }; + testStore = {}; + + const snap = await getHealthSnapshot({ timeoutMs: 10, probe: false }); + const byAgent = new Map(snap.agents.map((agent) => [agent.agentId, agent] as const)); + const main = byAgent.get("main"); + const ops = byAgent.get("ops"); + + expect(main?.heartbeat.everyMs).toBeNull(); + expect(main?.heartbeat.every).toBe("disabled"); + expect(ops?.heartbeat.everyMs).toBeTruthy(); + expect(ops?.heartbeat.every).toBe("1h"); + }); }); diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index 6f4ad8426..0cea428bf 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { HealthSummary } from "./health.js"; -import { healthCommand } from "./health.js"; +import { formatHealthChannelLines, healthCommand } from "./health.js"; const runtime = { log: vi.fn(), @@ -20,14 +20,23 @@ describe("healthCommand", () => { }); it("outputs JSON from gateway", async () => { + const agentSessions = { + path: "/tmp/sessions.json", + count: 1, + recent: [{ key: "+1555", updatedAt: Date.now(), age: 0 }], + }; const snapshot: HealthSummary = { ok: true, ts: Date.now(), durationMs: 5, channels: { - whatsapp: { linked: true, authAgeMs: 5000 }, - telegram: { configured: true, probe: { ok: true, elapsedMs: 1 } }, - discord: { configured: false }, + whatsapp: { accountId: "default", linked: true, authAgeMs: 5000 }, + telegram: { + accountId: "default", + configured: true, + probe: { ok: true, elapsedMs: 1 }, + }, + discord: { accountId: "default", configured: false }, }, channelOrder: ["whatsapp", "telegram", "discord"], channelLabels: { @@ -36,11 +45,23 @@ describe("healthCommand", () => { discord: "Discord", }, heartbeatSeconds: 60, - sessions: { - path: "/tmp/sessions.json", - count: 1, - recent: [{ key: "+1555", updatedAt: Date.now(), age: 0 }], - }, + defaultAgentId: "main", + agents: [ + { + agentId: "main", + isDefault: true, + heartbeat: { + enabled: true, + every: "1m", + everyMs: 60_000, + prompt: "hi", + target: "last", + ackMaxChars: 160, + }, + sessions: agentSessions, + }, + ], + sessions: agentSessions, }; callGatewayMock.mockResolvedValueOnce(snapshot); @@ -60,9 +81,9 @@ describe("healthCommand", () => { ts: Date.now(), durationMs: 5, channels: { - whatsapp: { linked: false, authAgeMs: null }, - telegram: { configured: false }, - discord: { configured: false }, + whatsapp: { accountId: "default", linked: false, authAgeMs: null }, + telegram: { accountId: "default", configured: false }, + discord: { accountId: "default", configured: false }, }, channelOrder: ["whatsapp", "telegram", "discord"], channelLabels: { @@ -71,6 +92,22 @@ describe("healthCommand", () => { discord: "Discord", }, heartbeatSeconds: 60, + defaultAgentId: "main", + agents: [ + { + agentId: "main", + isDefault: true, + heartbeat: { + enabled: true, + every: "1m", + everyMs: 60_000, + prompt: "hi", + target: "last", + ackMaxChars: 160, + }, + sessions: { path: "/tmp/sessions.json", count: 0, recent: [] }, + }, + ], sessions: { path: "/tmp/sessions.json", count: 0, recent: [] }, } satisfies HealthSummary); @@ -79,4 +116,61 @@ describe("healthCommand", () => { expect(runtime.exit).not.toHaveBeenCalled(); expect(runtime.log).toHaveBeenCalled(); }); + + it("formats per-account probe timings", () => { + const summary: HealthSummary = { + ok: true, + ts: Date.now(), + durationMs: 5, + channels: { + telegram: { + accountId: "main", + configured: true, + probe: { ok: true, elapsedMs: 196, bot: { username: "pinguini_ugi_bot" } }, + accounts: { + main: { + accountId: "main", + configured: true, + probe: { ok: true, elapsedMs: 196, bot: { username: "pinguini_ugi_bot" } }, + }, + flurry: { + accountId: "flurry", + configured: true, + probe: { ok: true, elapsedMs: 190, bot: { username: "flurry_ugi_bot" } }, + }, + poe: { + accountId: "poe", + configured: true, + probe: { ok: true, elapsedMs: 188, bot: { username: "poe_ugi_bot" } }, + }, + }, + }, + }, + channelOrder: ["telegram"], + channelLabels: { telegram: "Telegram" }, + heartbeatSeconds: 60, + defaultAgentId: "main", + agents: [ + { + agentId: "main", + isDefault: true, + heartbeat: { + enabled: true, + every: "1m", + everyMs: 60_000, + prompt: "hi", + target: "last", + ackMaxChars: 160, + }, + sessions: { path: "/tmp/sessions.json", count: 0, recent: [] }, + }, + ], + sessions: { path: "/tmp/sessions.json", count: 0, recent: [] }, + }; + + const lines = formatHealthChannelLines(summary, { accountMode: "all" }); + expect(lines).toContain( + "Telegram: ok (@pinguini_ugi_bot:main:196ms, @flurry_ugi_bot:flurry:190ms, @poe_ugi_bot:poe:188ms)", + ); + }); }); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index bddd74ec8..6bf78641a 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -32,6 +32,12 @@ const mocks = vi.hoisted(() => ({ configSnapshot: null, }), callGateway: vi.fn().mockResolvedValue({}), + listAgentsForGateway: vi.fn().mockReturnValue({ + defaultId: "main", + mainKey: "agent:main:main", + scope: "per-sender", + agents: [{ id: "main", name: "Main" }], + }), runSecurityAudit: vi.fn().mockResolvedValue({ ts: 0, summary: { critical: 1, warn: 1, info: 2 }, @@ -154,12 +160,7 @@ vi.mock("../gateway/call.js", async (importOriginal) => { return { ...actual, callGateway: mocks.callGateway }; }); vi.mock("../gateway/session-utils.js", () => ({ - listAgentsForGateway: () => ({ - defaultId: "main", - mainKey: "agent:main:main", - scope: "per-sender", - agents: [{ id: "main", name: "Main" }], - }), + listAgentsForGateway: mocks.listAgentsForGateway, })); vi.mock("../infra/clawdbot-root.js", () => ({ resolveClawdbotPackageRoot: vi.fn().mockResolvedValue("/tmp/clawdbot"), @@ -234,7 +235,7 @@ describe("statusCommand", () => { const payload = JSON.parse((runtime.log as vi.Mock).mock.calls[0][0]); expect(payload.linkChannel.linked).toBe(true); expect(payload.sessions.count).toBe(1); - expect(payload.sessions.path).toBe("/tmp/sessions.json"); + expect(payload.sessions.paths).toContain("/tmp/sessions.json"); expect(payload.sessions.defaults.model).toBeTruthy(); expect(payload.sessions.defaults.contextTokens).toBeGreaterThan(0); expect(payload.sessions.recent[0].percentUsed).toBe(50); @@ -335,4 +336,62 @@ describe("statusCommand", () => { expect(logs.join("\n")).toMatch(/gateway:/i); expect(logs.join("\n")).toMatch(/WARN/); }); + + it("includes sessions across agents in JSON output", async () => { + const originalAgents = mocks.listAgentsForGateway.getMockImplementation(); + const originalResolveStorePath = mocks.resolveStorePath.getMockImplementation(); + const originalLoadSessionStore = mocks.loadSessionStore.getMockImplementation(); + + mocks.listAgentsForGateway.mockReturnValue({ + defaultId: "main", + mainKey: "agent:main:main", + scope: "per-sender", + agents: [ + { id: "main", name: "Main" }, + { id: "ops", name: "Ops" }, + ], + }); + mocks.resolveStorePath.mockImplementation((_store, opts) => + opts?.agentId === "ops" ? "/tmp/ops.json" : "/tmp/main.json", + ); + mocks.loadSessionStore.mockImplementation((storePath) => { + if (storePath === "/tmp/ops.json") { + return { + "agent:ops:main": { + updatedAt: Date.now() - 120_000, + inputTokens: 1_000, + outputTokens: 1_000, + contextTokens: 10_000, + model: "pi:opus", + }, + }; + } + return { + "+1000": { + updatedAt: Date.now() - 60_000, + verboseLevel: "on", + thinkingLevel: "low", + inputTokens: 2_000, + outputTokens: 3_000, + contextTokens: 10_000, + model: "pi:opus", + sessionId: "abc123", + systemSent: true, + }, + }; + }); + + await statusCommand({ json: true }, runtime as never); + const payload = JSON.parse((runtime.log as vi.Mock).mock.calls.at(-1)?.[0]); + expect(payload.sessions.count).toBe(2); + expect(payload.sessions.paths.length).toBe(2); + expect(payload.sessions.recent.some((sess: { key?: string }) => sess.key === "agent:ops:main")) + .toBe(true); + + if (originalAgents) mocks.listAgentsForGateway.mockImplementation(originalAgents); + if (originalResolveStorePath) + mocks.resolveStorePath.mockImplementation(originalResolveStorePath); + if (originalLoadSessionStore) + mocks.loadSessionStore.mockImplementation(originalLoadSessionStore); + }); }); diff --git a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts index eb6c8ce2f..5b2f4b088 100644 --- a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts +++ b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts @@ -338,6 +338,43 @@ describe("legacy config detection", () => { } }); }); + it("auto-migrates bindings[].match.accountID on load and writes back", async () => { + await withTempHome(async (home) => { + const configPath = path.join(home, ".clawdbot", "clawdbot.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + bindings: [{ agentId: "main", match: { channel: "telegram", accountID: "work" } }], + }, + null, + 2, + ), + "utf-8", + ); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.resetModules(); + try { + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + expect(cfg.bindings?.[0]?.match?.accountId).toBe("work"); + + const raw = await fs.readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw) as { + bindings?: Array<{ match?: { accountId?: string; accountID?: string } }>; + }; + expect(parsed.bindings?.[0]?.match?.accountId).toBe("work"); + expect(parsed.bindings?.[0]?.match?.accountID).toBeUndefined(); + expect( + warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")), + ).toBe(true); + } finally { + warnSpy.mockRestore(); + } + }); + }); it("auto-migrates session.sendPolicy.rules[].match.provider on load and writes back", async () => { await withTempHome(async (home) => { const configPath = path.join(home, ".clawdbot", "clawdbot.json"); diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 6d5ebe07f..a9902f127 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -12,6 +12,7 @@ import { resolveStorePath, } from "../config/sessions.js"; import { + isHeartbeatEnabledForAgent, resolveHeartbeatIntervalMs, resolveHeartbeatPrompt, runHeartbeatOnce, @@ -81,6 +82,30 @@ describe("resolveHeartbeatPrompt", () => { }); }); +describe("isHeartbeatEnabledForAgent", () => { + it("enables only explicit heartbeat agents when configured", () => { + const cfg: ClawdbotConfig = { + agents: { + defaults: { heartbeat: { every: "30m" } }, + list: [{ id: "main" }, { id: "ops", heartbeat: { every: "1h" } }], + }, + }; + expect(isHeartbeatEnabledForAgent(cfg, "main")).toBe(false); + expect(isHeartbeatEnabledForAgent(cfg, "ops")).toBe(true); + }); + + it("falls back to default agent when no explicit heartbeat entries", () => { + const cfg: ClawdbotConfig = { + agents: { + defaults: { heartbeat: { every: "30m" } }, + list: [{ id: "main" }, { id: "ops" }], + }, + }; + expect(isHeartbeatEnabledForAgent(cfg, "main")).toBe(true); + expect(isHeartbeatEnabledForAgent(cfg, "ops")).toBe(false); + }); +}); + describe("resolveHeartbeatDeliveryTarget", () => { const baseEntry = { sessionId: "sid", @@ -214,6 +239,21 @@ describe("resolveHeartbeatDeliveryTarget", () => { }); describe("runHeartbeatOnce", () => { + it("skips when agent heartbeat is not enabled", async () => { + const cfg: ClawdbotConfig = { + agents: { + defaults: { heartbeat: { every: "30m" } }, + list: [{ id: "main" }, { id: "ops", heartbeat: { every: "1h" } }], + }, + }; + + const res = await runHeartbeatOnce({ cfg, agentId: "main" }); + expect(res.status).toBe("skipped"); + if (res.status === "skipped") { + expect(res.reason).toBe("disabled"); + } + }); + it("uses the last non-empty payload for delivery", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const storePath = path.join(tmpDir, "sessions.json");