Skip to content

Commit 2d1523e

Browse files
committed
fix: interpolate heartbeat response prefix templates (#73996) (thanks @yweiii and @JunJD)
1 parent 3295689 commit 2d1523e

3 files changed

Lines changed: 130 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
3333

3434
- Bonjour/Gateway: cap flapping advertiser restarts in a sliding window, so mDNS probing/name-conflict loops disable discovery instead of churning indefinitely on constrained hosts. Refs #74209 and #74242. Thanks @ndj888 and @Sanjays2402.
3535
- Plugins/runtime-deps: verify staged package entry files before reusing mirrored runtime roots, so browser-control repairs incomplete `ajv`/MCP SDK installs after update instead of failing after restart on a missing `ajv/dist/ajv.js`. Refs #74630. Thanks @spickeringlr.
36+
- Heartbeat: resolve `responsePrefix` template variables with the selected provider, model, and thinking context before delivering alerts or suppressing prefixed `HEARTBEAT_OK` replies. Fixes #43064; repairs #43065; supersedes #46858. Thanks @yweiii and @JunJD.
3637
- Channels/Feishu: retry file-typed iOS video resource downloads as `media` after a Feishu/Lark HTTP 502 and preserve the original 502 when the fallback also fails. Fixes #49855; carries forward #50164 and #73986. Thanks @alex-xuweilong.
3738
- Providers/Amazon Bedrock: expose the full Claude Opus 4.7 thinking profile (`xhigh`, `adaptive`, and `max`) for Bedrock model refs, while keeping Opus/Sonnet 4.6 on adaptive-by-default, so `/think` menus and validation match the Anthropic transport behavior. Fixes #74701. Thanks @prasad-yashdeep, @sparkleHazard, @Sanjays2402, and @hclsys.
3839
- Plugins/tokenjuice: compile the bundled plugin against tokenjuice 0.7.0's published OpenClaw host types instead of a local compatibility shim, so package contract drift fails in OpenClaw validation before release. Thanks @vincentkoc.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import type { OpenClawConfig } from "../config/config.js";
3+
import { runHeartbeatOnce, type HeartbeatDeps } from "./heartbeat-runner.js";
4+
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
5+
import {
6+
seedMainSessionStore,
7+
withTempTelegramHeartbeatSandbox,
8+
} from "./heartbeat-runner.test-utils.js";
9+
10+
installHeartbeatRunnerTestRuntime();
11+
12+
describe("runHeartbeatOnce responsePrefix templates", () => {
13+
const TELEGRAM_GROUP = "-1001234567890";
14+
15+
function createTelegramHeartbeatConfig(params: {
16+
tmpDir: string;
17+
storePath: string;
18+
responsePrefix: string;
19+
}): OpenClawConfig {
20+
return {
21+
agents: {
22+
defaults: {
23+
workspace: params.tmpDir,
24+
heartbeat: { every: "5m", target: "telegram" },
25+
},
26+
},
27+
channels: {
28+
telegram: {
29+
token: "test-token",
30+
allowFrom: ["*"],
31+
heartbeat: { showOk: false },
32+
},
33+
} as never,
34+
messages: { responsePrefix: params.responsePrefix },
35+
session: { store: params.storePath },
36+
};
37+
}
38+
39+
function makeTelegramDeps(params: { sendTelegram: ReturnType<typeof vi.fn> }): HeartbeatDeps {
40+
return {
41+
telegram: params.sendTelegram as unknown,
42+
getQueueSize: () => 0,
43+
nowMs: () => 0,
44+
} satisfies HeartbeatDeps;
45+
}
46+
47+
function createMessageSendSpy() {
48+
return vi.fn().mockResolvedValue({
49+
messageId: "m1",
50+
chatId: TELEGRAM_GROUP,
51+
});
52+
}
53+
54+
async function runTemplatedHeartbeat(params: { responsePrefix: string; replyText: string }) {
55+
return withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
56+
const cfg = createTelegramHeartbeatConfig({
57+
tmpDir,
58+
storePath,
59+
responsePrefix: params.responsePrefix,
60+
});
61+
await seedMainSessionStore(storePath, cfg, {
62+
lastChannel: "telegram",
63+
lastProvider: "telegram",
64+
lastTo: TELEGRAM_GROUP,
65+
});
66+
67+
replySpy.mockImplementation(async (_ctx, opts) => {
68+
opts?.onModelSelected?.({
69+
provider: "openai-codex",
70+
model: "gpt-5.4-20260401",
71+
thinkLevel: "high",
72+
});
73+
return { text: params.replyText };
74+
});
75+
const sendTelegram = createMessageSendSpy();
76+
77+
await runHeartbeatOnce({
78+
cfg,
79+
deps: {
80+
...makeTelegramDeps({ sendTelegram }),
81+
getReplyFromConfig: replySpy,
82+
},
83+
});
84+
85+
return sendTelegram;
86+
});
87+
}
88+
89+
it("resolves responsePrefix model-selection variables before alert delivery", async () => {
90+
const sendTelegram = await runTemplatedHeartbeat({
91+
responsePrefix: "[{provider}/{model}|think:{thinkingLevel}]",
92+
replyText: "Heartbeat alert",
93+
});
94+
95+
expect(sendTelegram).toHaveBeenCalledTimes(1);
96+
expect(sendTelegram).toHaveBeenCalledWith(
97+
TELEGRAM_GROUP,
98+
"[openai-codex/gpt-5.4|think:high] Heartbeat alert",
99+
expect.any(Object),
100+
);
101+
});
102+
103+
it("uses the resolved responsePrefix when suppressing prefixed HEARTBEAT_OK replies", async () => {
104+
const sendTelegram = await runTemplatedHeartbeat({
105+
responsePrefix: "[{model}]",
106+
replyText: "[gpt-5.4] HEARTBEAT_OK all good",
107+
});
108+
109+
expect(sendTelegram).not.toHaveBeenCalled();
110+
});
111+
});

src/infra/heartbeat-runner.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
resolveDefaultAgentId,
1313
} from "../agents/agent-scope.js";
1414
import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js";
15-
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
1615
import { isNestedAgentLane } from "../agents/lanes.js";
1716
import { resolveEmbeddedSessionLane } from "../agents/pi-embedded-runner/lanes.js";
1817
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
@@ -26,6 +25,7 @@ import {
2625
stripHeartbeatToken,
2726
type HeartbeatTask,
2827
} from "../auto-reply/heartbeat.js";
28+
import { resolveResponsePrefixTemplate } from "../auto-reply/reply/response-prefix-template.js";
2929
import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
3030
import type { ReplyPayload } from "../auto-reply/types.js";
3131
import { getChannelPlugin } from "../channels/plugins/index.js";
@@ -34,6 +34,7 @@ import type {
3434
ChannelId,
3535
ChannelPlugin,
3636
} from "../channels/plugins/types.public.js";
37+
import { createReplyPrefixContext } from "../channels/reply-prefix.js";
3738
import {
3839
listDueCommitmentsForSession,
3940
listDueCommitmentSessionKeys,
@@ -1056,10 +1057,12 @@ export async function runHeartbeatOnce(opts: {
10561057
})
10571058
: { showOk: false, showAlerts: true, useIndicator: true };
10581059
const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery });
1059-
const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId, {
1060+
const replyPrefix = createReplyPrefixContext({
1061+
cfg,
1062+
agentId,
10601063
channel: delivery.channel !== "none" ? delivery.channel : undefined,
10611064
accountId: delivery.accountId,
1062-
}).responsePrefix;
1065+
});
10631066

10641067
const canRelayToUser = Boolean(
10651068
delivery.channel !== "none" && delivery.to && visibility.showAlerts,
@@ -1231,7 +1234,15 @@ export async function runHeartbeatOnce(opts: {
12311234
nowMs: startedAt,
12321235
});
12331236

1234-
const heartbeatOkText = responsePrefix ? `${responsePrefix} ${HEARTBEAT_TOKEN}` : HEARTBEAT_TOKEN;
1237+
const resolveHeartbeatResponsePrefix = () =>
1238+
resolveResponsePrefixTemplate(
1239+
replyPrefix.responsePrefix,
1240+
replyPrefix.responsePrefixContextProvider(),
1241+
);
1242+
const resolveHeartbeatOkText = () => {
1243+
const responsePrefix = resolveHeartbeatResponsePrefix();
1244+
return responsePrefix ? `${responsePrefix} ${HEARTBEAT_TOKEN}` : HEARTBEAT_TOKEN;
1245+
};
12351246
const outboundSession = buildOutboundSessionContext({
12361247
cfg,
12371248
agentId,
@@ -1289,7 +1300,7 @@ export async function runHeartbeatOnce(opts: {
12891300
to: delivery.to,
12901301
accountId: delivery.accountId,
12911302
threadId: delivery.threadId,
1292-
payloads: [{ text: heartbeatOkText }],
1303+
payloads: [{ text: resolveHeartbeatOkText() }],
12931304
session: outboundSession,
12941305
deps: opts.deps,
12951306
});
@@ -1311,6 +1322,7 @@ export async function runHeartbeatOnce(opts: {
13111322
// Heartbeat timeout is a per-run override so user turns keep the global default.
13121323
timeoutOverrideSeconds,
13131324
bootstrapContextMode,
1325+
onModelSelected: replyPrefix.onModelSelected,
13141326
};
13151327
const getReplyFromConfig =
13161328
opts.deps?.getReplyFromConfig ?? (await loadHeartbeatRunnerRuntime()).getReplyFromConfig;
@@ -1350,6 +1362,7 @@ export async function runHeartbeatOnce(opts: {
13501362
}
13511363

13521364
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
1365+
const responsePrefix = resolveHeartbeatResponsePrefix();
13531366
const normalized = normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars);
13541367
// For exec completion events, don't skip even if the response looks like HEARTBEAT_OK.
13551368
// The model should be responding with exec results, not ack tokens.

0 commit comments

Comments
 (0)