fix(heartbeat): reuse the shared SDK reasoning classifier in the reply selector

This commit is contained in:
tangtaizhong666
2026-06-13 19:02:58 +08:00
committed by Vincent Koc
parent 8261858e09
commit 5885fbba0c
3 changed files with 33 additions and 36 deletions
@@ -59,4 +59,15 @@ describe("resolveHeartbeatReplyPayload", () => {
const blockquoted: ReplyPayload = { text: "Thinking... _weighing the options_" };
expect(resolveHeartbeatReplyPayload(blockquoted)).toBeUndefined();
});
it("skips a trailing lowercase 'reasoning:' payload and returns the final answer", () => {
const answer: ReplyPayload = { text: "All clear" };
const lowercased: ReplyPayload = { text: "reasoning: because nothing changed" };
expect(resolveHeartbeatReplyPayload([answer, lowercased])).toBe(answer);
});
it("returns undefined for a Markdown blockquoted thinking payload", () => {
const quoted: ReplyPayload = { text: "> thinking... _weighing the options_" };
expect(resolveHeartbeatReplyPayload(quoted)).toBeUndefined();
});
});
+11 -30
View File
@@ -1,39 +1,20 @@
// Heartbeat reply payload selector for multi-payload auto-reply results.
import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload";
import {
hasOutboundReplyContent,
isReasoningReplyPayload,
} from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "./types.js";
// Formatted reasoning prefixes the heartbeat reasoning lane classifies as
// reasoning even when the `isReasoning` flag is absent: legacy "Reasoning:"
// text and blockquoted "Thinking..._". Kept in sync with
// resolveHeartbeatReasoningPayloads (heartbeat-runner) so the selector skips
// exactly the payloads the reasoning lane delivers separately.
const FORMATTED_REASONING_PREFIX = /^(?:Reasoning:|Thinking\.{0,3}(?=\s*_))/u;
/** Whether text already carries a formatted reasoning prefix (legacy reasoning). */
export function hasFormattedReasoningPrefix(text: string): boolean {
return FORMATTED_REASONING_PREFIX.test(text.trimStart());
}
/**
* Whether a payload is reasoning (flagged or legacy-formatted) and so must not
* become the user-visible heartbeat reply.
*/
export function isReasoningReplyPayload(payload: ReplyPayload): boolean {
if (payload.isReasoning === true) {
return true;
}
const text = typeof payload.text === "string" ? payload.text : "";
return hasFormattedReasoningPrefix(text);
}
/**
* Pick the last outbound-capable reply payload for heartbeat delivery.
*
* Reasoning payloads are skipped: heartbeat reasoning is delivered separately
* and only when `includeReasoning` is enabled. Without this guard a trailing
* reasoning payload (flagged via `isReasoning` or legacy "Reasoning:" /
* blockquoted "Thinking" text, which reasoning models can emit after the final
* answer) would be selected as the user-visible heartbeat reply.
* Reasoning payloads are skipped using the shared SDK classifier
* `isReasoningReplyPayload`, which recognizes the `isReasoning` flag plus the
* common reasoning/thinking text prefixes (including lowercased and Markdown
* blockquoted forms). Heartbeat reasoning is delivered separately and only when
* `includeReasoning` is enabled; without this guard a trailing reasoning
* payload (which reasoning models can emit after the final answer) would be
* selected as the user-visible heartbeat reply.
*/
export function resolveHeartbeatReplyPayload(
replyResult: ReplyPayload | ReplyPayload[] | undefined,
+11 -6
View File
@@ -9,6 +9,7 @@ import {
} from "@openclaw/normalization-core/string-coerce";
import {
hasOutboundReplyContent,
isReasoningReplyPayload,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import { normalizeOptionalAgentRuntimeId } from "../agents/agent-runtime-id.js";
@@ -26,11 +27,7 @@ import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js";
import { resolveModelRefFromString, type ModelRef } from "../agents/model-selection.js";
import { resolvePersistedSessionRuntimeId } from "../agents/session-runtime-compat.js";
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
import {
hasFormattedReasoningPrefix,
isReasoningReplyPayload,
resolveHeartbeatReplyPayload,
} from "../auto-reply/heartbeat-reply-payload.js";
import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js";
import {
getHeartbeatToolNotificationText,
resolveHeartbeatToolResponseFromReplyResult,
@@ -757,6 +754,12 @@ function resolveStaleHeartbeatIsolatedSessionKey(params: {
return undefined;
}
// Display-format check (not a classifier): whether reasoning text is already
// rendered in the heartbeat "Reasoning:" / "Thinking..._" form, so it should be
// delivered as-is rather than re-wrapped by formatReasoningMessage. Reasoning
// classification itself is the shared SDK `isReasoningReplyPayload`.
const HEARTBEAT_REASONING_DISPLAY_PREFIX = /^(?:Reasoning:|Thinking\.{0,3}(?=\s*_))/u;
function resolveHeartbeatReasoningPayloads(
replyResult: ReplyPayload | ReplyPayload[] | undefined,
): ReplyPayload[] {
@@ -771,7 +774,9 @@ function resolveHeartbeatReasoningPayloads(
continue;
}
const formattedText = hasFormattedReasoningPrefix(text) ? text : formatReasoningMessage(text);
const formattedText = HEARTBEAT_REASONING_DISPLAY_PREFIX.test(text.trimStart())
? text
: formatReasoningMessage(text);
if (!formattedText.trim()) {
continue;
}