fix: interpolate heartbeat response prefix templates (#73996) (thanks @yweiii and @JunJD)

This commit is contained in:
Peter Steinberger
2026-04-30 03:46:51 +01:00
parent 329568905e
commit 2d1523e573
3 changed files with 130 additions and 5 deletions

View File

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

View File

@@ -0,0 +1,111 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { runHeartbeatOnce, type HeartbeatDeps } from "./heartbeat-runner.js";
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
import {
seedMainSessionStore,
withTempTelegramHeartbeatSandbox,
} from "./heartbeat-runner.test-utils.js";
installHeartbeatRunnerTestRuntime();
describe("runHeartbeatOnce responsePrefix templates", () => {
const TELEGRAM_GROUP = "-1001234567890";
function createTelegramHeartbeatConfig(params: {
tmpDir: string;
storePath: string;
responsePrefix: string;
}): OpenClawConfig {
return {
agents: {
defaults: {
workspace: params.tmpDir,
heartbeat: { every: "5m", target: "telegram" },
},
},
channels: {
telegram: {
token: "test-token",
allowFrom: ["*"],
heartbeat: { showOk: false },
},
} as never,
messages: { responsePrefix: params.responsePrefix },
session: { store: params.storePath },
};
}
function makeTelegramDeps(params: { sendTelegram: ReturnType<typeof vi.fn> }): HeartbeatDeps {
return {
telegram: params.sendTelegram as unknown,
getQueueSize: () => 0,
nowMs: () => 0,
} satisfies HeartbeatDeps;
}
function createMessageSendSpy() {
return vi.fn().mockResolvedValue({
messageId: "m1",
chatId: TELEGRAM_GROUP,
});
}
async function runTemplatedHeartbeat(params: { responsePrefix: string; replyText: string }) {
return withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
const cfg = createTelegramHeartbeatConfig({
tmpDir,
storePath,
responsePrefix: params.responsePrefix,
});
await seedMainSessionStore(storePath, cfg, {
lastChannel: "telegram",
lastProvider: "telegram",
lastTo: TELEGRAM_GROUP,
});
replySpy.mockImplementation(async (_ctx, opts) => {
opts?.onModelSelected?.({
provider: "openai-codex",
model: "gpt-5.4-20260401",
thinkLevel: "high",
});
return { text: params.replyText };
});
const sendTelegram = createMessageSendSpy();
await runHeartbeatOnce({
cfg,
deps: {
...makeTelegramDeps({ sendTelegram }),
getReplyFromConfig: replySpy,
},
});
return sendTelegram;
});
}
it("resolves responsePrefix model-selection variables before alert delivery", async () => {
const sendTelegram = await runTemplatedHeartbeat({
responsePrefix: "[{provider}/{model}|think:{thinkingLevel}]",
replyText: "Heartbeat alert",
});
expect(sendTelegram).toHaveBeenCalledTimes(1);
expect(sendTelegram).toHaveBeenCalledWith(
TELEGRAM_GROUP,
"[openai-codex/gpt-5.4|think:high] Heartbeat alert",
expect.any(Object),
);
});
it("uses the resolved responsePrefix when suppressing prefixed HEARTBEAT_OK replies", async () => {
const sendTelegram = await runTemplatedHeartbeat({
responsePrefix: "[{model}]",
replyText: "[gpt-5.4] HEARTBEAT_OK all good",
});
expect(sendTelegram).not.toHaveBeenCalled();
});
});

View File

@@ -12,7 +12,6 @@ import {
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js";
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
import { isNestedAgentLane } from "../agents/lanes.js";
import { resolveEmbeddedSessionLane } from "../agents/pi-embedded-runner/lanes.js";
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
@@ -26,6 +25,7 @@ import {
stripHeartbeatToken,
type HeartbeatTask,
} from "../auto-reply/heartbeat.js";
import { resolveResponsePrefixTemplate } from "../auto-reply/reply/response-prefix-template.js";
import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
@@ -34,6 +34,7 @@ import type {
ChannelId,
ChannelPlugin,
} from "../channels/plugins/types.public.js";
import { createReplyPrefixContext } from "../channels/reply-prefix.js";
import {
listDueCommitmentsForSession,
listDueCommitmentSessionKeys,
@@ -1056,10 +1057,12 @@ export async function runHeartbeatOnce(opts: {
})
: { showOk: false, showAlerts: true, useIndicator: true };
const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery });
const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId, {
const replyPrefix = createReplyPrefixContext({
cfg,
agentId,
channel: delivery.channel !== "none" ? delivery.channel : undefined,
accountId: delivery.accountId,
}).responsePrefix;
});
const canRelayToUser = Boolean(
delivery.channel !== "none" && delivery.to && visibility.showAlerts,
@@ -1231,7 +1234,15 @@ export async function runHeartbeatOnce(opts: {
nowMs: startedAt,
});
const heartbeatOkText = responsePrefix ? `${responsePrefix} ${HEARTBEAT_TOKEN}` : HEARTBEAT_TOKEN;
const resolveHeartbeatResponsePrefix = () =>
resolveResponsePrefixTemplate(
replyPrefix.responsePrefix,
replyPrefix.responsePrefixContextProvider(),
);
const resolveHeartbeatOkText = () => {
const responsePrefix = resolveHeartbeatResponsePrefix();
return responsePrefix ? `${responsePrefix} ${HEARTBEAT_TOKEN}` : HEARTBEAT_TOKEN;
};
const outboundSession = buildOutboundSessionContext({
cfg,
agentId,
@@ -1289,7 +1300,7 @@ export async function runHeartbeatOnce(opts: {
to: delivery.to,
accountId: delivery.accountId,
threadId: delivery.threadId,
payloads: [{ text: heartbeatOkText }],
payloads: [{ text: resolveHeartbeatOkText() }],
session: outboundSession,
deps: opts.deps,
});
@@ -1311,6 +1322,7 @@ export async function runHeartbeatOnce(opts: {
// Heartbeat timeout is a per-run override so user turns keep the global default.
timeoutOverrideSeconds,
bootstrapContextMode,
onModelSelected: replyPrefix.onModelSelected,
};
const getReplyFromConfig =
opts.deps?.getReplyFromConfig ?? (await loadHeartbeatRunnerRuntime()).getReplyFromConfig;
@@ -1350,6 +1362,7 @@ export async function runHeartbeatOnce(opts: {
}
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
const responsePrefix = resolveHeartbeatResponsePrefix();
const normalized = normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars);
// For exec completion events, don't skip even if the response looks like HEARTBEAT_OK.
// The model should be responding with exec results, not ack tokens.