Skip to content

Commit 6046c57

Browse files
committed
fix: interpolate responsePrefix template variables in heartbeat replies
The heartbeat runner bypassed `resolveResponsePrefixTemplate()`, causing template variables like `{model}` and `{provider}` to appear as literal text in heartbeat alert replies. Wire up `createReplyPrefixContext()` in `runHeartbeatOnce()` so the `onModelSelected` callback populates model/provider context, then interpolate the prefix template after the LLM responds. Fixes #43064
1 parent dc44413 commit 6046c57

2 files changed

Lines changed: 188 additions & 6 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
3+
import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js";
4+
import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js";
5+
import { setWhatsAppRuntime } from "../../extensions/whatsapp/src/runtime.js";
6+
import * as replyModule from "../auto-reply/reply.js";
7+
import type { OpenClawConfig } from "../config/config.js";
8+
import { resolveMainSessionKey } from "../config/sessions.js";
9+
import { setActivePluginRegistry } from "../plugins/runtime.js";
10+
import { createPluginRuntime } from "../plugins/runtime/index.js";
11+
import { createTestRegistry } from "../test-utils/channel-plugins.js";
12+
import { runHeartbeatOnce } from "./heartbeat-runner.js";
13+
import { seedSessionStore, withTempHeartbeatSandbox } from "./heartbeat-runner.test-utils.js";
14+
15+
// Avoid pulling optional runtime deps during isolated runs.
16+
vi.mock("jiti", () => ({ createJiti: () => () => ({}) }));
17+
18+
beforeEach(() => {
19+
const runtime = createPluginRuntime();
20+
setTelegramRuntime(runtime);
21+
setWhatsAppRuntime(runtime);
22+
setActivePluginRegistry(
23+
createTestRegistry([
24+
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
25+
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
26+
]),
27+
);
28+
});
29+
30+
afterEach(() => {
31+
vi.restoreAllMocks();
32+
});
33+
34+
describe("runHeartbeatOnce – responsePrefix template interpolation", () => {
35+
it("passes onModelSelected callback to getReplyFromConfig", async () => {
36+
await withTempHeartbeatSandbox(
37+
async ({ tmpDir, storePath }) => {
38+
const cfg: OpenClawConfig = {
39+
agents: {
40+
defaults: {
41+
workspace: tmpDir,
42+
heartbeat: { every: "5m", target: "whatsapp" },
43+
},
44+
},
45+
channels: {
46+
whatsapp: {
47+
allowFrom: ["*"],
48+
responsePrefix: "{model}: ",
49+
},
50+
},
51+
session: { store: storePath },
52+
};
53+
const sessionKey = resolveMainSessionKey(cfg);
54+
await seedSessionStore(storePath, sessionKey, {
55+
lastChannel: "whatsapp",
56+
lastProvider: "whatsapp",
57+
lastTo: "+1555",
58+
});
59+
60+
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
61+
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
62+
63+
await runHeartbeatOnce({
64+
cfg,
65+
deps: { getQueueSize: () => 0, nowMs: () => 0 },
66+
});
67+
68+
expect(replySpy).toHaveBeenCalledTimes(1);
69+
const replyOpts = replySpy.mock.calls[0]?.[1];
70+
expect(replyOpts).toHaveProperty("onModelSelected");
71+
expect(typeof replyOpts?.onModelSelected).toBe("function");
72+
},
73+
{ prefix: "openclaw-hb-prefix-" },
74+
);
75+
});
76+
77+
it("passes onModelSelected even without heartbeatModelOverride", async () => {
78+
await withTempHeartbeatSandbox(
79+
async ({ tmpDir, storePath }) => {
80+
const cfg: OpenClawConfig = {
81+
agents: {
82+
defaults: {
83+
workspace: tmpDir,
84+
heartbeat: { every: "5m", target: "whatsapp" },
85+
},
86+
},
87+
channels: {
88+
whatsapp: {
89+
allowFrom: ["*"],
90+
responsePrefix: "{model}: ",
91+
},
92+
},
93+
session: { store: storePath },
94+
};
95+
const sessionKey = resolveMainSessionKey(cfg);
96+
await seedSessionStore(storePath, sessionKey, {
97+
lastChannel: "whatsapp",
98+
lastProvider: "whatsapp",
99+
lastTo: "+1555",
100+
});
101+
102+
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
103+
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
104+
105+
await runHeartbeatOnce({
106+
cfg,
107+
deps: { getQueueSize: () => 0, nowMs: () => 0 },
108+
});
109+
110+
const replyOpts = replySpy.mock.calls[0]?.[1];
111+
// onModelSelected should be present regardless of model override
112+
expect(replyOpts).toEqual(
113+
expect.objectContaining({
114+
isHeartbeat: true,
115+
onModelSelected: expect.any(Function),
116+
}),
117+
);
118+
},
119+
{ prefix: "openclaw-hb-prefix-no-override-" },
120+
);
121+
});
122+
123+
it("passes onModelSelected with heartbeatModelOverride", async () => {
124+
await withTempHeartbeatSandbox(
125+
async ({ tmpDir, storePath }) => {
126+
const cfg: OpenClawConfig = {
127+
agents: {
128+
defaults: {
129+
workspace: tmpDir,
130+
heartbeat: { every: "5m", target: "whatsapp", model: "ollama/llama3.2:1b" },
131+
},
132+
},
133+
channels: {
134+
whatsapp: {
135+
allowFrom: ["*"],
136+
responsePrefix: "{model}: ",
137+
},
138+
},
139+
session: { store: storePath },
140+
};
141+
const sessionKey = resolveMainSessionKey(cfg);
142+
await seedSessionStore(storePath, sessionKey, {
143+
lastChannel: "whatsapp",
144+
lastProvider: "whatsapp",
145+
lastTo: "+1555",
146+
});
147+
148+
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
149+
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
150+
151+
await runHeartbeatOnce({
152+
cfg,
153+
deps: { getQueueSize: () => 0, nowMs: () => 0 },
154+
});
155+
156+
const replyOpts = replySpy.mock.calls[0]?.[1];
157+
expect(replyOpts).toEqual(
158+
expect.objectContaining({
159+
isHeartbeat: true,
160+
heartbeatModelOverride: "ollama/llama3.2:1b",
161+
onModelSelected: expect.any(Function),
162+
}),
163+
);
164+
},
165+
{ prefix: "openclaw-hb-prefix-with-override-" },
166+
);
167+
});
168+
});

src/infra/heartbeat-runner.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
resolveDefaultAgentId,
77
} from "../agents/agent-scope.js";
88
import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js";
9-
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
109
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
1110
import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js";
1211
import {
@@ -17,10 +16,12 @@ import {
1716
stripHeartbeatToken,
1817
} from "../auto-reply/heartbeat.js";
1918
import { getReplyFromConfig } from "../auto-reply/reply.js";
19+
import { resolveResponsePrefixTemplate } from "../auto-reply/reply/response-prefix-template.js";
2020
import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
2121
import type { ReplyPayload } from "../auto-reply/types.js";
2222
import { getChannelPlugin } from "../channels/plugins/index.js";
2323
import type { ChannelHeartbeatDeps } from "../channels/plugins/types.js";
24+
import { createReplyPrefixContext } from "../channels/reply-prefix.js";
2425
import { parseDurationMs } from "../cli/parse-duration.js";
2526
import type { OpenClawConfig } from "../config/config.js";
2627
import { loadConfig } from "../config/config.js";
@@ -682,10 +683,17 @@ export async function runHeartbeatOnce(opts: {
682683
})
683684
: { showOk: false, showAlerts: true, useIndicator: true };
684685
const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery });
685-
const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId, {
686-
channel: delivery.channel !== "none" ? delivery.channel : undefined,
686+
const deliveryChannel = delivery.channel !== "none" ? delivery.channel : undefined;
687+
const {
688+
responsePrefix: rawResponsePrefix,
689+
onModelSelected,
690+
prefixContext,
691+
} = createReplyPrefixContext({
692+
cfg,
693+
agentId,
694+
channel: deliveryChannel,
687695
accountId: delivery.accountId,
688-
}).responsePrefix;
696+
});
689697

690698
const canRelayToUser = Boolean(
691699
delivery.channel !== "none" && delivery.to && visibility.showAlerts,
@@ -720,7 +728,10 @@ export async function runHeartbeatOnce(opts: {
720728
return { status: "skipped", reason: "alerts-disabled" };
721729
}
722730

723-
const heartbeatOkText = responsePrefix ? `${responsePrefix} ${HEARTBEAT_TOKEN}` : HEARTBEAT_TOKEN;
731+
// heartbeatOkText is sent before the LLM runs, so use raw prefix (model unknown yet).
732+
const heartbeatOkText = rawResponsePrefix
733+
? `${rawResponsePrefix} ${HEARTBEAT_TOKEN}`
734+
: HEARTBEAT_TOKEN;
724735
const outboundSession = buildOutboundSessionContext({
725736
cfg,
726737
agentId,
@@ -775,8 +786,9 @@ export async function runHeartbeatOnce(opts: {
775786
heartbeatModelOverride,
776787
suppressToolErrorWarnings,
777788
bootstrapContextMode,
789+
onModelSelected,
778790
}
779-
: { isHeartbeat: true, suppressToolErrorWarnings, bootstrapContextMode };
791+
: { isHeartbeat: true, suppressToolErrorWarnings, bootstrapContextMode, onModelSelected };
780792
const replyResult = await getReplyFromConfig(ctx, replyOpts, cfg);
781793
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
782794
const includeReasoning = heartbeat?.includeReasoning === true;
@@ -809,6 +821,8 @@ export async function runHeartbeatOnce(opts: {
809821
}
810822

811823
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
824+
// Interpolate template variables (e.g. {model}, {provider}) now that the LLM has responded.
825+
const responsePrefix = resolveResponsePrefixTemplate(rawResponsePrefix, prefixContext);
812826
const normalized = normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars);
813827
// For exec completion events, don't skip even if the response looks like HEARTBEAT_OK.
814828
// The model should be responding with exec results, not ack tokens.

0 commit comments

Comments
 (0)