diff --git a/src/auto-reply/heartbeat-reply-payload.test.ts b/src/auto-reply/heartbeat-reply-payload.test.ts index 9d8387c073ed..7c9971113ff3 100644 --- a/src/auto-reply/heartbeat-reply-payload.test.ts +++ b/src/auto-reply/heartbeat-reply-payload.test.ts @@ -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(); + }); }); diff --git a/src/auto-reply/heartbeat-reply-payload.ts b/src/auto-reply/heartbeat-reply-payload.ts index ae2c8efb233e..62436347caf8 100644 --- a/src/auto-reply/heartbeat-reply-payload.ts +++ b/src/auto-reply/heartbeat-reply-payload.ts @@ -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, diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index e0965f03051f..bdec9dfb3c56 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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; }