From 6b83bcbafbaa45cbfeaa93469170bb327bfaaf00 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 12 Mar 2026 00:57:09 +0000 Subject: [PATCH] Build: sync main manifests and harden Matrix reasoning suppression --- .../matrix/src/matrix/monitor/replies.test.ts | 23 +++++++++++++++ .../matrix/src/matrix/monitor/replies.ts | 29 +++++++++++++++++++ src/auto-reply/reply/reply-payloads.test.ts | 25 ++++++++++++++++ src/auto-reply/reply/reply-payloads.ts | 14 ++++++++- 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/extensions/matrix/src/matrix/monitor/replies.test.ts b/extensions/matrix/src/matrix/monitor/replies.test.ts index 98fd857300a..f56a6e0e387 100644 --- a/extensions/matrix/src/matrix/monitor/replies.test.ts +++ b/extensions/matrix/src/matrix/monitor/replies.test.ts @@ -137,6 +137,29 @@ describe("deliverMatrixReplies", () => { ); }); + it("suppresses reasoning-only text before Matrix sends", async () => { + await deliverMatrixReplies({ + cfg, + replies: [ + { text: "Reasoning:\n_hidden_" }, + { text: "still hidden" }, + { text: "Visible answer" }, + ], + roomId: "room:5", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "off", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(sendMessageMatrixMock).toHaveBeenCalledWith( + "room:5", + "Visible answer", + expect.objectContaining({ cfg }), + ); + }); + it("uses supplied cfg for chunking and send delivery without reloading runtime config", async () => { const explicitCfg = { channels: { diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index 12582e0c42e..5b47a8d1d98 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -8,6 +8,31 @@ import { getMatrixRuntime } from "../../runtime.js"; import type { MatrixClient } from "../sdk.js"; import { sendMessageMatrix } from "../send.js"; +const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>/gi; +const THINKING_BLOCK_RE = + /<\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>[\s\S]*?<\s*\/\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi; + +function shouldSuppressReasoningReplyText(text?: string): boolean { + if (typeof text !== "string") { + return false; + } + const trimmedStart = text.trimStart(); + if (!trimmedStart) { + return false; + } + if (trimmedStart.toLowerCase().startsWith("reasoning:")) { + return true; + } + THINKING_TAG_RE.lastIndex = 0; + if (!THINKING_TAG_RE.test(text)) { + return false; + } + THINKING_BLOCK_RE.lastIndex = 0; + const withoutThinkingBlocks = text.replace(THINKING_BLOCK_RE, ""); + THINKING_TAG_RE.lastIndex = 0; + return !withoutThinkingBlocks.replace(THINKING_TAG_RE, "").trim(); +} + export async function deliverMatrixReplies(params: { cfg: OpenClawConfig; replies: ReplyPayload[]; @@ -37,6 +62,10 @@ export async function deliverMatrixReplies(params: { const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "matrix", params.accountId); let hasReplied = false; for (const reply of params.replies) { + if (reply.isReasoning === true || shouldSuppressReasoningReplyText(reply.text)) { + logVerbose("matrix reply suppressed as reasoning-only"); + continue; + } const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0; if (!reply?.text && !hasMedia) { if (reply?.audioAsVoice) { diff --git a/src/auto-reply/reply/reply-payloads.test.ts b/src/auto-reply/reply/reply-payloads.test.ts index 8664eec5c72..20f0206aa1b 100644 --- a/src/auto-reply/reply/reply-payloads.test.ts +++ b/src/auto-reply/reply/reply-payloads.test.ts @@ -3,6 +3,7 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { filterMessagingToolMediaDuplicates, + shouldSuppressReasoningPayload, shouldSuppressMessagingToolReplies, } from "./reply-payloads.js"; @@ -170,3 +171,27 @@ describe("shouldSuppressMessagingToolReplies", () => { ).toBe(true); }); }); + +describe("shouldSuppressReasoningPayload", () => { + it("suppresses raw reasoning-prefix text even when isReasoning is absent", () => { + expect(shouldSuppressReasoningPayload({ text: " Reasoning:\n_hidden_" })).toBe(true); + }); + + it("suppresses thinking-tag-only text even when isReasoning is absent", () => { + expect(shouldSuppressReasoningPayload({ text: "hidden" })).toBe(true); + }); + + it("does not suppress text that merely mentions reasoning mid-message", () => { + expect( + shouldSuppressReasoningPayload({ + text: "Intro line\nReasoning: appears in content but is not a prefix", + }), + ).toBe(false); + }); + + it("does not suppress messages that contain an answer outside thinking tags", () => { + expect(shouldSuppressReasoningPayload({ text: "hiddenVisible answer" })).toBe( + false, + ); + }); +}); diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index 7d7ae82975c..0b34830dd98 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -6,6 +6,7 @@ import type { ReplyToMode } from "../../config/types.js"; import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; import { normalizeOptionalAccountId } from "../../routing/account-id.js"; +import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; import { extractReplyToTag } from "./reply-tags.js"; @@ -86,7 +87,18 @@ export function isRenderablePayload(payload: ReplyPayload): boolean { } export function shouldSuppressReasoningPayload(payload: ReplyPayload): boolean { - return payload.isReasoning === true; + if (payload.isReasoning === true) { + return true; + } + const text = payload.text; + if (typeof text !== "string") { + return false; + } + if (text.trimStart().toLowerCase().startsWith("reasoning:")) { + return true; + } + const stripped = stripReasoningTagsFromText(text, { mode: "strict", trim: "both" }); + return !stripped && stripped !== text; } export function applyReplyThreading(params: {