Skip to content

Commit 8a88fc4

Browse files
Merge 808add5 into c85113e
2 parents c85113e + 808add5 commit 8a88fc4

4 files changed

Lines changed: 185 additions & 9 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Heartbeat reply payload selector tests.
2+
import { describe, expect, it } from "vitest";
3+
import { resolveHeartbeatReplyPayload } from "./heartbeat-reply-payload.js";
4+
import type { ReplyPayload } from "./types.js";
5+
6+
describe("resolveHeartbeatReplyPayload", () => {
7+
it("returns a single non-array payload unchanged", () => {
8+
const payload: ReplyPayload = { text: "HEARTBEAT_OK" };
9+
expect(resolveHeartbeatReplyPayload(payload)).toBe(payload);
10+
});
11+
12+
it("returns undefined for undefined input", () => {
13+
expect(resolveHeartbeatReplyPayload(undefined)).toBeUndefined();
14+
});
15+
16+
it("returns the last outbound payload when none are reasoning", () => {
17+
const first: ReplyPayload = { text: "first" };
18+
const second: ReplyPayload = { text: "second" };
19+
expect(resolveHeartbeatReplyPayload([first, second])).toBe(second);
20+
});
21+
22+
it("skips a trailing reasoning payload and returns the assistant answer", () => {
23+
const answer: ReplyPayload = { text: "HEARTBEAT_OK" };
24+
const reasoning: ReplyPayload = {
25+
text: "The message is an OpenClaw heartbeat poll. I should check recent chat...",
26+
isReasoning: true,
27+
};
28+
expect(resolveHeartbeatReplyPayload([answer, reasoning])).toBe(answer);
29+
});
30+
31+
it("returns undefined when every outbound payload is reasoning", () => {
32+
const reasoning: ReplyPayload = {
33+
text: "Deliberating about whether to respond...",
34+
isReasoning: true,
35+
};
36+
expect(resolveHeartbeatReplyPayload([reasoning])).toBeUndefined();
37+
});
38+
39+
it("returns undefined for a scalar reasoning payload", () => {
40+
const reasoning: ReplyPayload = {
41+
text: "Considering whether the heartbeat needs a reply...",
42+
isReasoning: true,
43+
};
44+
expect(resolveHeartbeatReplyPayload(reasoning)).toBeUndefined();
45+
});
46+
47+
it("skips a trailing legacy 'Reasoning:'-prefixed payload and returns the final answer", () => {
48+
const answer: ReplyPayload = { text: "All clear" };
49+
const legacyReasoning: ReplyPayload = { text: "Reasoning: because nothing changed" };
50+
expect(resolveHeartbeatReplyPayload([answer, legacyReasoning])).toBe(answer);
51+
});
52+
53+
it("returns undefined for a scalar legacy 'Reasoning:'-prefixed payload", () => {
54+
const legacyReasoning: ReplyPayload = { text: "Reasoning: because nothing changed" };
55+
expect(resolveHeartbeatReplyPayload(legacyReasoning)).toBeUndefined();
56+
});
57+
58+
it("returns undefined for a scalar blockquoted 'Thinking' reasoning payload", () => {
59+
const blockquoted: ReplyPayload = { text: "Thinking... _weighing the options_" };
60+
expect(resolveHeartbeatReplyPayload(blockquoted)).toBeUndefined();
61+
});
62+
63+
it("skips a trailing lowercase 'reasoning:' payload and returns the final answer", () => {
64+
const answer: ReplyPayload = { text: "All clear" };
65+
const lowercased: ReplyPayload = { text: "reasoning: because nothing changed" };
66+
expect(resolveHeartbeatReplyPayload([answer, lowercased])).toBe(answer);
67+
});
68+
69+
it("returns undefined for a Markdown blockquoted thinking payload", () => {
70+
const quoted: ReplyPayload = { text: "> thinking... _weighing the options_" };
71+
expect(resolveHeartbeatReplyPayload(quoted)).toBeUndefined();
72+
});
73+
});

src/auto-reply/heartbeat-reply-payload.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,38 @@
11
// Heartbeat reply payload selector for multi-payload auto-reply results.
2-
import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload";
2+
import { hasOutboundReplyContent, isReasoningReplyPayload } from "openclaw/plugin-sdk/reply-payload";
33
import type { ReplyPayload } from "./types.js";
44

5-
/** Pick the last outbound-capable reply payload for heartbeat delivery. */
5+
/**
6+
* Pick the last outbound-capable reply payload for heartbeat delivery.
7+
*
8+
* Reasoning payloads are skipped using the shared SDK classifier
9+
* `isReasoningReplyPayload`, which recognizes the `isReasoning` flag plus the
10+
* common reasoning/thinking text prefixes (including lowercased and Markdown
11+
* blockquoted forms). Heartbeat reasoning is delivered separately and only when
12+
* `includeReasoning` is enabled; without this guard a trailing reasoning
13+
* payload (which reasoning models can emit after the final answer) would be
14+
* selected as the user-visible heartbeat reply.
15+
*/
616
export function resolveHeartbeatReplyPayload(
717
replyResult: ReplyPayload | ReplyPayload[] | undefined,
818
): ReplyPayload | undefined {
919
if (!replyResult) {
1020
return undefined;
1121
}
1222
if (!Array.isArray(replyResult)) {
13-
return replyResult;
23+
// Scalar results can be reasoning-only too; without this guard a scalar
24+
// reasoning payload becomes the user-visible reply while the array path
25+
// filters it, so the leak depends on the result shape.
26+
return isReasoningReplyPayload(replyResult) ? undefined : replyResult;
1427
}
1528
for (let idx = replyResult.length - 1; idx >= 0; idx -= 1) {
1629
const payload = replyResult[idx];
1730
if (!payload) {
1831
continue;
1932
}
33+
if (isReasoningReplyPayload(payload)) {
34+
continue;
35+
}
2036
if (hasOutboundReplyContent(payload)) {
2137
return payload;
2238
}

src/infra/heartbeat-runner.returns-default-unset.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1286,6 +1286,15 @@ describe("runHeartbeatOnce", () => {
12861286
replies: [{ text: "Because it helps", isReasoning: true }, { text: "HEARTBEAT_OK" }],
12871287
expectedTexts: ["Thinking\n\n_Because it helps_"],
12881288
},
1289+
{
1290+
// Reasoning-only result: the selector returns no main reply, but the
1291+
// documented includeReasoning opt-in must still deliver the Thinking
1292+
// message instead of going silent (#92242 follow-up / review finding).
1293+
name: "raw flagged reasoning only (no main reply)",
1294+
caseDir: "hb-reasoning-only",
1295+
replies: [{ text: "Because it helps", isReasoning: true }],
1296+
expectedTexts: ["Thinking\n\n_Because it helps_"],
1297+
},
12891298
{
12901299
name: "visible final that starts with thinking prose",
12911300
caseDir: "hb-thinking-visible-final",
@@ -1371,6 +1380,67 @@ describe("runHeartbeatOnce", () => {
13711380
},
13721381
);
13731382

1383+
it("does not surface a trailing legacy reasoning payload as the reply when includeReasoning is unset", async () => {
1384+
// With includeReasoning unset, a legacy "Reasoning:"-prefixed payload after
1385+
// the final answer must not become the visible heartbeat reply, and no
1386+
// separate Thinking message is sent. (#92242 review follow-up)
1387+
const replySpy = vi.fn();
1388+
try {
1389+
const tmpDir = await createCaseDir("hb-legacy-reasoning-unset");
1390+
const storePath = path.join(tmpDir, "sessions.json");
1391+
const cfg: OpenClawConfig = {
1392+
agents: {
1393+
defaults: {
1394+
workspace: tmpDir,
1395+
heartbeat: { every: "5m", target: "whatsapp" },
1396+
},
1397+
},
1398+
channels: { whatsapp: { allowFrom: ["*"] } },
1399+
session: { store: storePath },
1400+
};
1401+
const sessionKey = resolveMainSessionKey(cfg);
1402+
await fs.writeFile(
1403+
storePath,
1404+
JSON.stringify({
1405+
[sessionKey]: {
1406+
sessionId: "sid",
1407+
updatedAt: Date.now(),
1408+
lastChannel: "whatsapp",
1409+
lastProvider: "whatsapp",
1410+
lastTo: "[email protected]",
1411+
},
1412+
}),
1413+
);
1414+
1415+
replySpy.mockResolvedValue([
1416+
{ text: "All clear" },
1417+
{ text: "Reasoning: because nothing changed" },
1418+
]);
1419+
const sendWhatsApp = vi
1420+
.fn<
1421+
(
1422+
to: string,
1423+
text: string,
1424+
opts?: unknown,
1425+
) => Promise<{ messageId: string; toJid: string }>
1426+
>()
1427+
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
1428+
1429+
await runHeartbeatOnce({
1430+
cfg,
1431+
deps: createHeartbeatDeps(sendWhatsApp, { getReplyFromConfig: replySpy }),
1432+
});
1433+
1434+
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
1435+
expectWhatsAppSendCall(sendWhatsApp, 0, {
1436+
1437+
text: "All clear",
1438+
});
1439+
} finally {
1440+
replySpy.mockReset();
1441+
}
1442+
});
1443+
13741444
it("loads the default agent session from templated stores", async () => {
13751445
const tmpDir = await createCaseDir("openclaw-hb");
13761446
const storeTemplate = path.join(tmpDir, "agents", "{agentId}", "sessions.json");

src/infra/heartbeat-runner.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "@openclaw/normalization-core/string-coerce";
1010
import {
1111
hasOutboundReplyContent,
12+
isReasoningReplyPayload,
1213
resolveSendableOutboundReplyParts,
1314
} from "openclaw/plugin-sdk/reply-payload";
1415
import { normalizeOptionalAgentRuntimeId } from "../agents/agent-runtime-id.js";
@@ -753,21 +754,29 @@ function resolveStaleHeartbeatIsolatedSessionKey(params: {
753754
return undefined;
754755
}
755756

757+
// Display-format check (not a classifier): whether reasoning text is already
758+
// rendered in the heartbeat "Reasoning:" / "Thinking..._" form, so it should be
759+
// delivered as-is rather than re-wrapped by formatReasoningMessage. Reasoning
760+
// classification itself is the shared SDK `isReasoningReplyPayload`.
761+
const HEARTBEAT_REASONING_DISPLAY_PREFIX = /^(?:Reasoning:|Thinking\.{0,3}(?=\s*_))/u;
762+
756763
function resolveHeartbeatReasoningPayloads(
757764
replyResult: ReplyPayload | ReplyPayload[] | undefined,
758765
): ReplyPayload[] {
759766
const payloads = Array.isArray(replyResult) ? replyResult : replyResult ? [replyResult] : [];
760767
const reasoningPayloads: ReplyPayload[] = [];
761768
for (const payload of payloads) {
762769
const text = typeof payload.text === "string" ? payload.text : "";
763-
const hasFormattedReasoningPrefix = /^(?:Reasoning:|Thinking\.{0,3}(?=\s*_))/u.test(
764-
text.trimStart(),
765-
);
766-
if (payload.isReasoning !== true && !hasFormattedReasoningPrefix) {
770+
// Shared classifier keeps this lane in lockstep with the heartbeat reply
771+
// selector so a legacy-formatted reasoning payload is never both skipped
772+
// here and surfaced as the visible reply (or vice versa). See #92242.
773+
if (!isReasoningReplyPayload(payload)) {
767774
continue;
768775
}
769776

770-
const formattedText = hasFormattedReasoningPrefix ? text : formatReasoningMessage(text);
777+
const formattedText = HEARTBEAT_REASONING_DISPLAY_PREFIX.test(text.trimStart())
778+
? text
779+
: formatReasoningMessage(text);
771780
if (!formattedText.trim()) {
772781
continue;
773782
}
@@ -1878,7 +1887,15 @@ export async function runHeartbeatOnce(opts: {
18781887
return { status: "ran", durationMs: Date.now() - startedAt };
18791888
}
18801889

1881-
if (!heartbeatToolResponse && (!replyPayload || !hasOutboundReplyContent(replyPayload))) {
1890+
if (
1891+
!heartbeatToolResponse &&
1892+
(!replyPayload || !hasOutboundReplyContent(replyPayload)) &&
1893+
reasoningPayloads.length === 0
1894+
) {
1895+
// No main reply to send. Only treat this as an empty heartbeat when there
1896+
// is also no opt-in reasoning to deliver; otherwise fall through so the
1897+
// includeReasoning Thinking payload is still sent (mirrors the
1898+
// shouldSkipMain guard below). See #92242 follow-up.
18821899
await restoreHeartbeatUpdatedAt({
18831900
storePath,
18841901
sessionKey,

0 commit comments

Comments
 (0)