feat(heartbeat): add accountId config option for multi-agent routing (#8702)

* feat(heartbeat): add accountId config option for multi-agent routing

Add optional accountId field to heartbeat configuration, allowing
multi-agent setups to explicitly specify which Telegram account
should be used for heartbeat delivery.

Previously, heartbeat delivery would use the accountId from the
session's deliveryContext. When a session had no prior conversation
history, heartbeats would default to the first/primary account
instead of the agent's intended bot.

Changes:
- Add accountId to HeartbeatSchema (zod-schema.agent-runtime.ts)
- Use heartbeat.accountId with fallback to session accountId (targets.ts)

Backward compatible: if accountId is not specified, behavior is unchanged.

Closes #8695

* fix: improve heartbeat accountId routing (#8702) (thanks @lsh411)

* fix: harden heartbeat accountId routing (#8702) (thanks @lsh411)

* fix: expose heartbeat accountId in status (#8702) (thanks @lsh411)

* chore: format status + heartbeat tests (#8702) (thanks @lsh411)

---------

Co-authored-by: m1 16 512 <m116512@m1ui-MacBookAir-2.local>
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
This commit is contained in:
lsh411
2026-02-05 06:49:12 +09:00
committed by GitHub
parent bebf323775
commit a42e3cb78a
10 changed files with 267 additions and 9 deletions

View File

@@ -25,7 +25,10 @@ import {
resolveHeartbeatPrompt,
runHeartbeatOnce,
} from "./heartbeat-runner.js";
import { resolveHeartbeatDeliveryTarget } from "./outbound/targets.js";
import {
resolveHeartbeatDeliveryTarget,
resolveHeartbeatSenderContext,
} from "./outbound/targets.js";
// Avoid pulling optional runtime deps during isolated runs.
vi.mock("jiti", () => ({ createJiti: () => () => ({}) }));
@@ -264,6 +267,42 @@ describe("resolveHeartbeatDeliveryTarget", () => {
});
});
it("uses explicit heartbeat accountId when provided", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
heartbeat: { target: "telegram", to: "123", accountId: "work" },
},
},
channels: { telegram: { accounts: { work: { botToken: "token" } } } },
};
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
channel: "telegram",
to: "123",
accountId: "work",
lastChannel: undefined,
lastAccountId: undefined,
});
});
it("skips when explicit heartbeat accountId is unknown", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
heartbeat: { target: "telegram", to: "123", accountId: "missing" },
},
},
channels: { telegram: { accounts: { work: { botToken: "token" } } } },
};
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
channel: "none",
reason: "unknown-account",
accountId: "missing",
lastChannel: undefined,
lastAccountId: undefined,
});
});
it("prefers per-agent heartbeat overrides when provided", () => {
const cfg: OpenClawConfig = {
agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } },
@@ -285,6 +324,39 @@ describe("resolveHeartbeatDeliveryTarget", () => {
});
});
describe("resolveHeartbeatSenderContext", () => {
it("prefers delivery accountId for allowFrom resolution", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
allowFrom: ["111"],
accounts: {
work: { allowFrom: ["222"], botToken: "token" },
},
},
},
};
const entry = {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "telegram" as const,
lastTo: "111",
lastAccountId: "default",
};
const delivery = {
channel: "telegram" as const,
to: "999",
accountId: "work",
lastChannel: "telegram" as const,
lastAccountId: "default",
};
const ctx = resolveHeartbeatSenderContext({ cfg, entry, delivery });
expect(ctx.allowFrom).toEqual(["222"]);
});
});
describe("runHeartbeatOnce", () => {
it("skips when agent heartbeat is not enabled", async () => {
const cfg: OpenClawConfig = {