diff --git a/CHANGELOG.md b/CHANGELOG.md index 95b47d6fb49..06aaa60c138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/infra/heartbeat-runner.response-prefix-template.test.ts b/src/infra/heartbeat-runner.response-prefix-template.test.ts new file mode 100644 index 00000000000..c2dadbd7536 --- /dev/null +++ b/src/infra/heartbeat-runner.response-prefix-template.test.ts @@ -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 }): 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(); + }); +}); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 897a713c67c..47088902480 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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.