mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:00:42 +00:00
fix: interpolate heartbeat response prefix templates (#73996) (thanks @yweiii and @JunJD)
This commit is contained in:
@@ -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.
|
||||
|
||||
111
src/infra/heartbeat-runner.response-prefix-template.test.ts
Normal file
111
src/infra/heartbeat-runner.response-prefix-template.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user