test: update health/status and legacy migration coverage
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user