Skip to content

Commit 8f29862

Browse files
Hackerismydreammartinvincentkoc
authored
fix: silence heartbeat notify=false fallback replies (#100735)
* fix(heartbeat): consume trailing notify=false fallback marker * fix(heartbeat): keep notify=false marker fully silent --------- Co-authored-by: martin <[email protected]> Co-authored-by: Vincent Koc <[email protected]>
1 parent cbf9acf commit 8f29862

3 files changed

Lines changed: 105 additions & 8 deletions

File tree

src/infra/heartbeat-runner.tool-response.test.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
4040
modelRuntimeId?: string;
4141
model?: string;
4242
target?: "telegram" | "last";
43+
showOk?: boolean;
4344
}): OpenClawConfig {
4445
return {
4546
agents: {
@@ -67,7 +68,7 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
6768
telegram: {
6869
token: "test-token",
6970
allowFrom: ["*"],
70-
heartbeat: { showOk: false },
71+
heartbeat: { showOk: params.showOk ?? false },
7172
},
7273
},
7374
session: { store: params.storePath },
@@ -88,7 +89,7 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
8889

8990
function expectTelegramSend(
9091
sendTelegram: ReturnType<typeof vi.fn>,
91-
params: { text: string; cfg: OpenClawConfig },
92+
params: { text: string; cfg: OpenClawConfig; silent?: boolean },
9293
) {
9394
expect(sendTelegram).toHaveBeenCalledTimes(1);
9495
expect(sendTelegram.mock.calls).toEqual([
@@ -99,6 +100,7 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
99100
verbose: false,
100101
cfg: params.cfg,
101102
accountId: undefined,
103+
...(params.silent !== undefined ? { silent: params.silent } : {}),
102104
},
103105
],
104106
]);
@@ -156,6 +158,26 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
156158
});
157159
}
158160

161+
async function runPlainFallbackReply(text: string, options: { showOk?: boolean } = {}) {
162+
return await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
163+
const cfg = createConfig({ tmpDir, storePath, showOk: options.showOk });
164+
await seedMainSessionStore(storePath, cfg, {
165+
lastChannel: "telegram",
166+
lastProvider: "telegram",
167+
lastTo: TELEGRAM_GROUP,
168+
});
169+
replySpy.mockResolvedValue({ text });
170+
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1" });
171+
172+
const result = await runHeartbeatOnce({
173+
cfg,
174+
deps: createDeps({ sendTelegram, getReplyFromConfig: replySpy }),
175+
});
176+
177+
return { result, sendTelegram, replySpy, cfg };
178+
});
179+
}
180+
159181
async function runPromptScenario(
160182
params: {
161183
config?: Partial<Parameters<typeof createConfig>[0]>;
@@ -237,6 +259,54 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
237259
});
238260
});
239261

262+
it.each(["", "\n", "\r\n"])(
263+
"converts trailing notify=false fallback text into silent Telegram delivery with suffix %j",
264+
async (suffix) => {
265+
const { result, sendTelegram, cfg } = await runPlainFallbackReply(
266+
`No interruption needed.\n\nnotify=false${suffix}`,
267+
);
268+
269+
expect(result.status).toBe("ran");
270+
expectTelegramSend(sendTelegram, {
271+
text: "No interruption needed.",
272+
cfg,
273+
silent: true,
274+
});
275+
expect(getLastHeartbeatEvent()).toMatchObject({
276+
status: "sent",
277+
preview: "No interruption needed.",
278+
channel: "telegram",
279+
silent: true,
280+
});
281+
},
282+
);
283+
284+
it("suppresses marker-only notify=false fallback replies", async () => {
285+
const { result, sendTelegram } = await runPlainFallbackReply("notify=false\r\n", {
286+
showOk: true,
287+
});
288+
289+
expect(result.status).toBe("ran");
290+
expect(sendTelegram).not.toHaveBeenCalled();
291+
expect(getLastHeartbeatEvent()).toMatchObject({
292+
status: "ok-token",
293+
channel: "telegram",
294+
silent: true,
295+
});
296+
});
297+
298+
it("preserves inline notify=false fallback text", async () => {
299+
const { result, sendTelegram, cfg } = await runPlainFallbackReply(
300+
"The literal notify=false flag is documented.",
301+
);
302+
303+
expect(result.status).toBe("ran");
304+
expectTelegramSend(sendTelegram, {
305+
text: "The literal notify=false flag is documented.",
306+
cfg,
307+
});
308+
});
309+
240310
it("uses the heartbeat response tool prompt in message-tool mode", async () => {
241311
const result = await runPromptScenario({
242312
config: { visibleReplies: "message_tool" },

src/infra/heartbeat-runner.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -845,6 +845,7 @@ type NormalizedHeartbeatDelivery = {
845845
text: string;
846846
hasMedia: boolean;
847847
isInternalPlaceholderOnly: boolean;
848+
silent?: boolean;
848849
};
849850

850851
function isStreamErrorFallbackPlaceholderOnly(text: string): boolean {
@@ -859,6 +860,16 @@ function isStreamErrorFallbackPlaceholderOnly(text: string): boolean {
859860
return remaining.length === 0;
860861
}
861862

863+
const TRAILING_HEARTBEAT_NOTIFY_FALSE_RE = /(?:^|[\r\n])[ \t]*notify=false[ \t]*(?:\r?\n[ \t]*)*$/i;
864+
865+
function stripTrailingHeartbeatNotifyFalse(text: string): { text: string; silent: boolean } {
866+
const match = TRAILING_HEARTBEAT_NOTIFY_FALSE_RE.exec(text);
867+
if (!match) {
868+
return { text, silent: false };
869+
}
870+
return { text: text.slice(0, match.index).trimEnd(), silent: true };
871+
}
872+
862873
function normalizeHeartbeatReply(
863874
payload: ReplyPayload,
864875
responsePrefix: string | undefined,
@@ -871,20 +882,29 @@ function normalizeHeartbeatReply(
871882
maxAckChars: ackMaxChars,
872883
});
873884
const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia;
874-
const isInternalPlaceholderOnly = isStreamErrorFallbackPlaceholderOnly(stripped.text);
885+
const notifyFalse = stripTrailingHeartbeatNotifyFalse(stripped.text);
886+
const isInternalPlaceholderOnly = isStreamErrorFallbackPlaceholderOnly(notifyFalse.text);
875887
if ((stripped.shouldSkip || isInternalPlaceholderOnly) && !hasMedia) {
876888
return {
877889
shouldSkip: true,
878890
text: "",
879891
hasMedia,
880892
isInternalPlaceholderOnly,
893+
...(notifyFalse.silent ? { silent: true } : {}),
881894
};
882895
}
883-
let finalText = isInternalPlaceholderOnly ? "" : stripped.text;
896+
let finalText = isInternalPlaceholderOnly ? "" : notifyFalse.text;
884897
if (responsePrefix && finalText && !finalText.startsWith(responsePrefix)) {
885898
finalText = `${responsePrefix} ${finalText}`;
886899
}
887-
return { shouldSkip: false, text: finalText, hasMedia, isInternalPlaceholderOnly };
900+
const shouldSkip = !hasMedia && finalText.trim().length === 0;
901+
return {
902+
shouldSkip,
903+
text: finalText,
904+
hasMedia,
905+
isInternalPlaceholderOnly,
906+
...(notifyFalse.silent ? { silent: true } : {}),
907+
};
888908
}
889909

890910
function normalizeHeartbeatToolNotification(
@@ -2059,8 +2079,12 @@ export async function runHeartbeatOnce(opts: {
20592079
? replyPayload.text.trim()
20602080
: null;
20612081
if (execFallbackText) {
2062-
normalized.text = execFallbackText;
2063-
normalized.shouldSkip = false;
2082+
const execNotifyFalse = stripTrailingHeartbeatNotifyFalse(execFallbackText);
2083+
normalized.text = execNotifyFalse.text;
2084+
normalized.shouldSkip = !normalized.hasMedia && !normalized.text.trim();
2085+
if (execNotifyFalse.silent) {
2086+
normalized.silent = true;
2087+
}
20642088
}
20652089
const replacement = !heartbeatToolResponse
20662090
? replaceGenericExternalRunFailureText(normalized.text)
@@ -2081,7 +2105,7 @@ export async function runHeartbeatOnce(opts: {
20812105
updatedAt: previousUpdatedAt,
20822106
});
20832107

2084-
const okSent = await maybeSendHeartbeatOk();
2108+
const okSent = normalized.silent ? false : await maybeSendHeartbeatOk();
20852109
emitHeartbeatEvent({
20862110
status: "ok-token",
20872111
reason: opts.reason,
@@ -2238,6 +2262,7 @@ export async function runHeartbeatOnce(opts: {
22382262
]),
22392263
],
22402264
deps: opts.deps,
2265+
silent: normalized.silent,
22412266
});
22422267
if (send.status === "failed" || send.status === "partial_failed") {
22432268
throw send.error;
@@ -2292,6 +2317,7 @@ export async function runHeartbeatOnce(opts: {
22922317
hasMedia: mediaUrls.length > 0,
22932318
channel: delivery.channel,
22942319
accountId: delivery.accountId,
2320+
...(normalized.silent === true ? { silent: true } : {}),
22952321
indicatorType: visibility.useIndicator ? resolveIndicatorType(eventStatus) : undefined,
22962322
});
22972323
await updateTaskTimestamps();

test/helpers/infra/heartbeat-runner-channel-plugins.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ function createHeartbeatOutboundAdapter(channelId: HeartbeatSendChannelId): Chan
4040
...baseOptions,
4141
...(typeof threadId === "number" ? { messageThreadId: threadId } : {}),
4242
...(typeof replyToId === "string" ? { replyToMessageId: Number(replyToId) } : {}),
43+
...(opts.silent !== undefined ? { silent: opts.silent } : {}),
4344
}
4445
: {
4546
...baseOptions,

0 commit comments

Comments
 (0)