Skip to content

Commit faab6d0

Browse files
fix: interpolate responsePrefix template variables in heartbeat replies
1 parent 43da089 commit faab6d0

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
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313

1414
### Fixes
1515

16+
- 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.
1617
- Gateway/shutdown: report structured shutdown warnings and HTTP close timeout warnings through `ShutdownResult` while preserving lifecycle hook hardening. Carries forward #41296. Thanks @edenfunf.
1718
- Plugins/QA: prebuild the private QA channel runtime before plugin gauntlet source runs so wrapper CPU/RSS measurements are not polluted by private QA dist rebuild work. Thanks @vincentkoc.
1819
- Gateway/reload: bound default restart deferral and SIGUSR1 restart drain to five minutes while preserving explicit `deferralTimeoutMs: 0` indefinite waits, so stale active work accounting cannot block config reloads forever. 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
@@ -11,7 +11,6 @@ import {
1111
resolveDefaultAgentId,
1212
} from "../agents/agent-scope.js";
1313
import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js";
14-
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
1514
import { resolveEmbeddedSessionLane } from "../agents/pi-embedded-runner/lanes.js";
1615
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
1716
import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js";
@@ -24,6 +23,7 @@ import {
2423
stripHeartbeatToken,
2524
type HeartbeatTask,
2625
} from "../auto-reply/heartbeat.js";
26+
import { resolveResponsePrefixTemplate } from "../auto-reply/reply/response-prefix-template.js";
2727
import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
2828
import type { ReplyPayload } from "../auto-reply/types.js";
2929
import { getChannelPlugin } from "../channels/plugins/index.js";
@@ -32,6 +32,7 @@ import type {
3232
ChannelId,
3333
ChannelPlugin,
3434
} from "../channels/plugins/types.public.js";
35+
import { createReplyPrefixContext } from "../channels/reply-prefix.js";
3536
import { getRuntimeConfig } from "../config/config.js";
3637
import {
3738
canonicalizeMainSessionAlias,
@@ -832,10 +833,12 @@ export async function runHeartbeatOnce(opts: {
832833
})
833834
: { showOk: false, showAlerts: true, useIndicator: true };
834835
const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery });
835-
const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId, {
836+
const replyPrefix = createReplyPrefixContext({
837+
cfg,
838+
agentId,
836839
channel: delivery.channel !== "none" ? delivery.channel : undefined,
837840
accountId: delivery.accountId,
838-
}).responsePrefix;
841+
});
839842

840843
const canRelayToUser = Boolean(
841844
delivery.channel !== "none" && delivery.to && visibility.showAlerts,
@@ -993,7 +996,15 @@ export async function runHeartbeatOnce(opts: {
993996
return { status: "skipped", reason: "alerts-disabled" };
994997
}
995998

996-
const heartbeatOkText = responsePrefix ? `${responsePrefix} ${HEARTBEAT_TOKEN}` : HEARTBEAT_TOKEN;
999+
const resolveHeartbeatResponsePrefix = () =>
1000+
resolveResponsePrefixTemplate(
1001+
replyPrefix.responsePrefix,
1002+
replyPrefix.responsePrefixContextProvider(),
1003+
);
1004+
const resolveHeartbeatOkText = () => {
1005+
const responsePrefix = resolveHeartbeatResponsePrefix();
1006+
return responsePrefix ? `${responsePrefix} ${HEARTBEAT_TOKEN}` : HEARTBEAT_TOKEN;
1007+
};
9971008
const outboundSession = buildOutboundSessionContext({
9981009
cfg,
9991010
agentId,
@@ -1051,7 +1062,7 @@ export async function runHeartbeatOnce(opts: {
10511062
to: delivery.to,
10521063
accountId: delivery.accountId,
10531064
threadId: delivery.threadId,
1054-
payloads: [{ text: heartbeatOkText }],
1065+
payloads: [{ text: resolveHeartbeatOkText() }],
10551066
session: outboundSession,
10561067
deps: opts.deps,
10571068
});
@@ -1073,6 +1084,7 @@ export async function runHeartbeatOnce(opts: {
10731084
// Heartbeat timeout is a per-run override so user turns keep the global default.
10741085
timeoutOverrideSeconds,
10751086
bootstrapContextMode,
1087+
onModelSelected: replyPrefix.onModelSelected,
10761088
};
10771089
const getReplyFromConfig =
10781090
opts.deps?.getReplyFromConfig ?? (await loadHeartbeatRunnerRuntime()).getReplyFromConfig;
@@ -1106,6 +1118,7 @@ export async function runHeartbeatOnce(opts: {
11061118
}
11071119

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

0 commit comments

Comments
 (0)