fix(matrix): prevent private reasoning from reaching rooms (#99412) (#115553)

This commit is contained in:
Peter Steinberger
2026-07-29 00:55:07 -04:00
committed by GitHub
parent 8982d47a55
commit dbe645a08f
2 changed files with 98 additions and 19 deletions
@@ -183,6 +183,9 @@ describe("deliverMatrixReplies", () => {
replies: [
{ text: "Reasoning:\n_hidden_" },
{ text: "<think>still hidden</think>" },
{ text: "<mm:think>MiniMax private reasoning</mm:think>" },
{ text: "<mm:thought>MiniMax private thought</mm:thought>" },
{ text: "<antml:thinking>Anthropic private reasoning</antml:thinking>" },
{ 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 `<mm:think>example</mm:think>` 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: "<mm:think>MiniMax private reasoning</mm:think>Visible MiniMax answer" },
{ text: "<antml:thinking>Anthropic private reasoning</antml:thinking>Visible answer" },
{ text: "<br>Visible HTML answer<mm:think>MiniMax private reasoning</mm:think>" },
{ text: "Visible safe answer<mm:think>unfinished private reasoning" },
{ text: "Visible answer<think>old reasoning</think><think>unfinished private reasoning" },
{ text: "<thinking>private reasoning</think>Visible alias answer" },
{ text: "<final>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("<br>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: "<mm:think>MiniMax private reasoning</mm:think>",
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: {
+11 -19
View File
@@ -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