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: {