From dbe645a08f6167225bea6ee5157bebb562f61835 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 00:55:07 -0400 Subject: [PATCH] fix(matrix): prevent private reasoning from reaching rooms (#99412) (#115553) --- .../matrix/src/matrix/monitor/replies.test.ts | 87 +++++++++++++++++++ .../matrix/src/matrix/monitor/replies.ts | 30 +++---- 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/extensions/matrix/src/matrix/monitor/replies.test.ts b/extensions/matrix/src/matrix/monitor/replies.test.ts index 09a0e7342de5..17523109990e 100644 --- a/extensions/matrix/src/matrix/monitor/replies.test.ts +++ b/extensions/matrix/src/matrix/monitor/replies.test.ts @@ -183,6 +183,9 @@ describe("deliverMatrixReplies", () => { replies: [ { text: "Reasoning:\n_hidden_" }, { text: "still hidden" }, + { text: "MiniMax private reasoning" }, + { text: "MiniMax private thought" }, + { text: "Anthropic private reasoning" }, { text: "Visible answer" }, ], roomId: "room:5", @@ -198,6 +201,90 @@ describe("deliverMatrixReplies", () => { expect(sendOptions(0).cfg).toBe(cfg); }); + it("delivers literal reasoning tags inside Markdown code", async () => { + const text = "Use `example` literally."; + + await deliverMatrixReplies({ + cfg, + replies: [{ text }], + roomId: "room:5", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "off", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(sendCall(0)[1]).toBe(text); + }); + + it("strips namespaced reasoning while delivering visible Matrix replies", async () => { + await deliverMatrixReplies({ + cfg, + replies: [ + { text: "MiniMax private reasoningVisible MiniMax answer" }, + { text: "Anthropic private reasoningVisible answer" }, + { text: "
Visible HTML answerMiniMax private reasoning" }, + { text: "Visible safe answerunfinished private reasoning" }, + { text: "Visible answerold reasoningunfinished private reasoning" }, + { text: "private reasoningVisible alias answer" }, + { text: "Visible final answer" }, + ], + roomId: "room:5", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "off", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(7); + expect(sendCall(0)[1]).toBe("Visible MiniMax answer"); + expect(sendCall(1)[1]).toBe("Visible answer"); + expect(sendCall(2)[1]).toBe("
Visible HTML answer"); + expect(sendCall(3)[1]).toBe("Visible safe answer"); + expect(sendCall(4)[1]).toBe("Visible answer"); + expect(sendCall(5)[1]).toBe("Visible alias answer"); + expect(sendCall(6)[1]).toBe("Visible final answer"); + }); + + it("preserves significant whitespace in visible Markdown replies", async () => { + const text = " indented Markdown code\n\nVisible line with a hard break \nnext line"; + + await deliverMatrixReplies({ + cfg, + replies: [{ text }], + roomId: "room:5", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "off", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(sendCall(0)[1]).toBe(text.trim()); + }); + + it("delivers Matrix media without a reasoning-only caption", async () => { + await deliverMatrixReplies({ + cfg, + replies: [ + { + text: "MiniMax private reasoning", + mediaUrl: "https://example.com/a.jpg", + }, + ], + roomId: "room:5", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "off", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(sendCall(0)[1]).toBe(""); + expect(sendOptions(0).mediaUrl).toBe("https://example.com/a.jpg"); + }); + 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 695c939a4b46..377465cf1fb8 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -1,33 +1,24 @@ // Matrix plugin module implements replies behavior. import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking"; import { getMatrixRuntime } from "../../runtime.js"; import type { MatrixClient } from "../sdk.js"; import { chunkMatrixText, sendMessageMatrix } from "../send.js"; import type { MarkdownTableMode, OpenClawConfig, ReplyPayload, RuntimeEnv } from "./runtime-api.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 { +function resolveVisibleMatrixReplyText(text?: string): string | undefined { if (typeof text !== "string") { - return false; + return undefined; } const trimmedStart = text.trimStart(); if (!trimmedStart) { - return false; + return text; } if (normalizeLowercaseStringOrEmpty(trimmedStart).startsWith("reasoning:")) { - return true; + return undefined; } - 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(); + const visibleText = stripReasoningTagsFromText(text, { mode: "strict", trim: "none" }); + return visibleText.trim() ? visibleText : undefined; } export async function deliverMatrixReplies(params: { @@ -60,11 +51,12 @@ export async function deliverMatrixReplies(params: { let hasReplied = false; let deliveredAny = false; for (const reply of params.replies) { - if (reply.isReasoning === true || shouldSuppressReasoningReplyText(reply.text)) { + const visibleText = resolveVisibleMatrixReplyText(reply.text); + const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0; + if (reply.isReasoning === true || (!hasMedia && reply.text && visibleText === undefined)) { 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) { logVerbose("matrix reply has audioAsVoice without media/text; skipping"); @@ -79,7 +71,7 @@ export async function deliverMatrixReplies(params: { : params.replyToMode === "off" ? undefined : replyToIdRaw; - const rawText = reply.text ?? ""; + const rawText = visibleText ?? ""; const mediaList = reply.mediaUrls?.length ? reply.mediaUrls : reply.mediaUrl