fix: normalize heartbeat targets

This commit is contained in:
Peter Steinberger
2026-01-24 13:17:02 +00:00
parent 9d742ba51f
commit ef7971e3a4
13 changed files with 163 additions and 42 deletions

View File

@@ -9,6 +9,7 @@ async function writePluginFixture(params: {
dir: string;
id: string;
schema: Record<string, unknown>;
channels?: string[];
}) {
await fs.mkdir(params.dir, { recursive: true });
await fs.writeFile(
@@ -16,16 +17,16 @@ async function writePluginFixture(params: {
`export default { id: "${params.id}", register() {} };`,
"utf-8",
);
const manifest: Record<string, unknown> = {
id: params.id,
configSchema: params.schema,
};
if (params.channels) {
manifest.channels = params.channels;
}
await fs.writeFile(
path.join(params.dir, "clawdbot.plugin.json"),
JSON.stringify(
{
id: params.id,
configSchema: params.schema,
},
null,
2,
),
JSON.stringify(manifest, null, 2),
"utf-8",
);
}
@@ -149,4 +150,43 @@ describe("config plugin validation", () => {
expect(res.ok).toBe(true);
});
});
it("accepts plugin heartbeat targets", async () => {
await withTempHome(async (home) => {
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
const pluginDir = path.join(home, "bluebubbles-plugin");
await writePluginFixture({
dir: pluginDir,
id: "bluebubbles-plugin",
channels: ["bluebubbles"],
schema: { type: "object" },
});
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { defaults: { heartbeat: { target: "bluebubbles" } }, list: [{ id: "pi" }] },
plugins: { enabled: false, load: { paths: [pluginDir] } },
});
expect(res.ok).toBe(true);
});
});
it("rejects unknown heartbeat targets", async () => {
await withTempHome(async (home) => {
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { defaults: { heartbeat: { target: "not-a-channel" } }, list: [{ id: "pi" }] },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues).toContainEqual({
path: "agents.defaults.heartbeat.target",
message: "unknown heartbeat target: not-a-channel",
});
}
});
});
});

View File

@@ -84,4 +84,22 @@ describe("config schema", () => {
const channelProps = channelSchema?.properties as Record<string, unknown> | undefined;
expect(channelProps?.accessToken).toBeTruthy();
});
it("adds heartbeat target hints with dynamic channels", () => {
const res = buildConfigSchema({
channels: [
{
id: "bluebubbles",
label: "BlueBubbles",
configSchema: { type: "object" },
},
],
});
const defaultsHint = res.uiHints["agents.defaults.heartbeat.target"];
const listHint = res.uiHints["agents.list.*.heartbeat.target"];
expect(defaultsHint?.help).toContain("bluebubbles");
expect(defaultsHint?.help).toContain("last");
expect(listHint?.help).toContain("bluebubbles");
});
});

View File

@@ -1,3 +1,4 @@
import { CHANNEL_IDS } from "../channels/registry.js";
import { VERSION } from "../version.js";
import { ClawdbotSchema } from "./zod-schema.js";
@@ -807,6 +808,44 @@ function applyChannelHints(hints: ConfigUiHints, channels: ChannelUiMetadata[]):
return next;
}
function listHeartbeatTargetChannels(channels: ChannelUiMetadata[]): string[] {
const seen = new Set<string>();
const ordered: string[] = [];
for (const id of CHANNEL_IDS) {
const normalized = id.trim().toLowerCase();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
ordered.push(normalized);
}
for (const channel of channels) {
const normalized = channel.id.trim().toLowerCase();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
ordered.push(normalized);
}
return ordered;
}
function applyHeartbeatTargetHints(
hints: ConfigUiHints,
channels: ChannelUiMetadata[],
): ConfigUiHints {
const next: ConfigUiHints = { ...hints };
const channelList = listHeartbeatTargetChannels(channels);
const channelHelp = channelList.length ? ` Known channels: ${channelList.join(", ")}.` : "";
const help = `Delivery target ("last", "none", or a channel id).${channelHelp}`;
const paths = ["agents.defaults.heartbeat.target", "agents.list.*.heartbeat.target"];
for (const path of paths) {
const current = next[path] ?? {};
next[path] = {
...current,
help: current.help ?? help,
placeholder: current.placeholder ?? "last",
};
}
return next;
}
function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]): ConfigSchema {
const next = cloneSchema(schema);
const root = asSchemaObject(next);
@@ -908,7 +947,10 @@ export function buildConfigSchema(params?: {
const channels = params?.channels ?? [];
if (plugins.length === 0 && channels.length === 0) return base;
const mergedHints = applySensitiveHints(
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
applyHeartbeatTargetHints(
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
channels,
),
);
const mergedSchema = applyChannelSchemas(applyPluginSchemas(base.schema, plugins), channels);
return {

View File

@@ -4,6 +4,7 @@ import type {
HumanDelayConfig,
TypingMode,
} from "./types.base.js";
import type { ChannelId } from "../channels/plugins/types.js";
import type {
SandboxBrowserSettings,
SandboxDockerSettings,
@@ -177,18 +178,8 @@ export type AgentDefaultsConfig = {
model?: string;
/** Session key for heartbeat runs ("main" or explicit session key). */
session?: string;
/** Delivery target (last|whatsapp|telegram|discord|slack|mattermost|msteams|signal|imessage|none). */
target?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "mattermost"
| "msteams"
| "signal"
| "imessage"
| "none";
/** Delivery target ("last", "none", or a channel id). */
target?: "last" | "none" | ChannelId;
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
to?: string;
/** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."). */

View File

@@ -1,7 +1,7 @@
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { CHANNEL_IDS } from "../channels/registry.js";
import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js";
import {
normalizePluginsConfig,
resolveEnableState,
@@ -226,6 +226,41 @@ export function validateConfigObjectWithPlugins(raw: unknown):
}
}
const heartbeatChannelIds = new Set<string>();
for (const channelId of CHANNEL_IDS) {
heartbeatChannelIds.add(channelId.toLowerCase());
}
for (const record of registry.plugins) {
for (const channelId of record.channels) {
const trimmed = channelId.trim();
if (trimmed) heartbeatChannelIds.add(trimmed.toLowerCase());
}
}
const validateHeartbeatTarget = (target: string | undefined, path: string) => {
if (typeof target !== "string") return;
const trimmed = target.trim();
if (!trimmed) {
issues.push({ path, message: "heartbeat target must not be empty" });
return;
}
const normalized = trimmed.toLowerCase();
if (normalized === "last" || normalized === "none") return;
if (normalizeChatChannelId(trimmed)) return;
if (heartbeatChannelIds.has(normalized)) return;
issues.push({ path, message: `unknown heartbeat target: ${target}` });
};
validateHeartbeatTarget(
config.agents?.defaults?.heartbeat?.target,
"agents.defaults.heartbeat.target",
);
if (Array.isArray(config.agents?.list)) {
for (const [index, entry] of config.agents.list.entries()) {
validateHeartbeatTarget(entry?.heartbeat?.target, `agents.list.${index}.heartbeat.target`);
}
}
let selectedMemoryPluginId: string | null = null;
const seenPlugins = new Set<string>();
for (const record of registry.plugins) {

View File

@@ -22,19 +22,7 @@ export const HeartbeatSchema = z
model: z.string().optional(),
session: z.string().optional(),
includeReasoning: z.boolean().optional(),
target: z
.union([
z.literal("last"),
z.literal("whatsapp"),
z.literal("telegram"),
z.literal("discord"),
z.literal("slack"),
z.literal("msteams"),
z.literal("signal"),
z.literal("imessage"),
z.literal("none"),
])
.optional(),
target: z.string().optional(),
to: z.string().optional(),
prompt: z.string().optional(),
ackMaxChars: z.number().int().nonnegative().optional(),