fix: suppress Mattermost quoted reasoning replies (#69927) (thanks @lawrence3699)

This commit is contained in:
Peter Steinberger
2026-04-22 03:58:04 +01:00
parent bb43c7b89f
commit 23a017be7c
10 changed files with 86 additions and 39 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Mattermost: suppress reasoning-only payloads even when they arrive as blockquoted `> Reasoning:` text, preventing `/reasoning on` from leaking thinking into channel posts. (#69927) Thanks @lawrence3699.
- Browser/Chrome MCP: reset cached existing-session control sessions when a `navigate_page` call times out, so one stuck navigation no longer poisons the browser profile until a gateway restart. (#69733) Thanks @ayeshakhalid192007-dev.
- Browser/Chrome MCP: propagate click timeouts and abort signals to existing-session actions so a stuck click fails fast and reconnects instead of poisoning the browser tool until gateway restart. (#63524) Thanks @dongseok0.
- OpenCode Go: canonicalize stale bundled `opencode-go` base URLs from `/go` or `/go/v1` to `/zen/go` or `/zen/go/v1`, so older generated model metadata stops hitting the 404 HTML endpoint. (#69898)

View File

@@ -1,2 +1,2 @@
d7f6e6ecdfb78c73760689af5a684c20ec7ca28509d4f63bf0d990a2d739c6ce plugin-sdk-api-baseline.json
584681e4436a4e84c2ff20196ff194a63915caf4dda70de9c27f34ab0d7bde0b plugin-sdk-api-baseline.jsonl
8ac8add8354dc1af76b9aa6f15f7fdcc5265b0bdaf72ea7fc1d3d11bc9f74b8c plugin-sdk-api-baseline.json
83310e1d3ea75e9216300ed36e61fdfcfdb6bba7d5c0df62cbfe03ec93565b73 plugin-sdk-api-baseline.jsonl

View File

@@ -257,7 +257,7 @@ describe("deliverMattermostReplyWithDraftPreview", () => {
const deliverFinal = vi.fn(async () => {});
await deliverMattermostReplyWithDraftPreview({
payload: { text: " \n Reasoning:\n_hidden_" } as never,
payload: { text: " \n > Reasoning:\n> _hidden_" } as never,
info: { kind: "final" },
client: createMattermostClientMock(),
draftStream,

View File

@@ -1,5 +1,6 @@
import { deliverFinalizableDraftPreview } from "openclaw/plugin-sdk/channel-lifecycle";
import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
import { isReasoningReplyPayload } from "openclaw/plugin-sdk/reply-payload";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import {
normalizeLowercaseStringOrEmpty,
@@ -55,10 +56,7 @@ import {
type MattermostWebSocketFactory,
} from "./monitor-websocket.js";
import { runWithReconnect } from "./reconnect.js";
import {
deliverMattermostReplyPayload,
shouldSuppressMattermostReasoningReply,
} from "./reply-delivery.js";
import { deliverMattermostReplyPayload } from "./reply-delivery.js";
import type {
ChannelAccountSnapshot,
ChatType,
@@ -292,7 +290,7 @@ type MattermostDraftPreviewDeliverParams = {
export async function deliverMattermostReplyWithDraftPreview(
params: MattermostDraftPreviewDeliverParams,
): Promise<void> {
if (shouldSuppressMattermostReasoningReply(params.payload)) {
if (isReasoningReplyPayload(params.payload)) {
return;
}

View File

@@ -80,6 +80,27 @@ describe("deliverMattermostReplyPayload", () => {
expect(sendMessage).not.toHaveBeenCalled();
});
it("suppresses reasoning payloads formatted as a Mattermost blockquote", async () => {
const sendMessage = vi.fn(async () => undefined);
const cfg = {} satisfies OpenClawConfig;
const core = createReplyDeliveryCore();
await deliverMattermostReplyPayload({
core,
cfg,
payload: { text: "> Reasoning:\n> _hidden_" },
to: "channel:town-square",
accountId: "default",
agentId: "agent-1",
replyToId: "root-post",
textLimit: 4000,
tableMode: "off",
sendMessage,
});
expect(sendMessage).not.toHaveBeenCalled();
});
it("does not suppress messages that mention Reasoning: mid-text", async () => {
const sendMessage = vi.fn(async () => undefined);
const cfg = {} satisfies OpenClawConfig;

View File

@@ -1,8 +1,8 @@
import {
deliverTextOrMediaReply,
isReasoningReplyPayload,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import {
getAgentScopedMediaLocalRoots,
type OpenClawConfig,
@@ -24,19 +24,6 @@ type SendMattermostMessage = (
},
) => Promise<unknown>;
const REASONING_PREFIX = "reasoning:";
export function shouldSuppressMattermostReasoningReply(payload: ReplyPayload): boolean {
if (payload.isReasoning === true) {
return true;
}
const text = payload.text;
if (typeof text !== "string") {
return false;
}
return normalizeLowercaseStringOrEmpty(text.trimStart()).startsWith(REASONING_PREFIX);
}
export async function deliverMattermostReplyPayload(params: {
core: PluginRuntime;
cfg: OpenClawConfig;
@@ -49,7 +36,7 @@ export async function deliverMattermostReplyPayload(params: {
tableMode: MarkdownTableMode;
sendMessage: SendMattermostMessage;
}): Promise<void> {
if (shouldSuppressMattermostReasoningReply(params.payload)) {
if (isReasoningReplyPayload(params.payload)) {
return;
}
const reply = resolveSendableOutboundReplyParts(params.payload, {

View File

@@ -101,6 +101,10 @@ describe("deliverWebReply", () => {
await expectReplySuppressed({ text: " \n Reasoning:\n_hidden_" });
});
it("suppresses payloads that start with a quoted reasoning prefix", async () => {
await expectReplySuppressed({ text: " > Reasoning:\n> _hidden_" });
});
it("does not suppress messages that mention Reasoning: mid-text", async () => {
const msg = makeMsg();

View File

@@ -2,11 +2,11 @@ import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-chunking";
import {
isReasoningReplyPayload,
resolveOutboundMediaUrls,
sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload";
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { loadWebMedia } from "../media.js";
import { newConnectionId } from "../reconnect.js";
import { formatError } from "../session.js";
@@ -16,19 +16,6 @@ import { whatsappOutboundLog } from "./loggers.js";
import type { WebInboundMsg } from "./types.js";
import { elide } from "./util.js";
const REASONING_PREFIX = "reasoning:";
function shouldSuppressReasoningReply(payload: ReplyPayload): boolean {
if (payload.isReasoning === true) {
return true;
}
const text = payload.text;
if (typeof text !== "string") {
return false;
}
return normalizeLowercaseStringOrEmpty(text.trimStart()).startsWith(REASONING_PREFIX);
}
export async function deliverWebReply(params: {
replyResult: ReplyPayload;
msg: WebInboundMsg;
@@ -46,7 +33,7 @@ export async function deliverWebReply(params: {
}) {
const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params;
const replyStarted = Date.now();
if (shouldSuppressReasoningReply(replyResult)) {
if (isReasoningReplyPayload(replyResult)) {
whatsappOutboundLog.debug(`Suppressed reasoning payload to ${msg.from}`);
return;
}

View File

@@ -6,6 +6,7 @@ import {
hasOutboundMedia,
hasOutboundReplyContent,
hasOutboundText,
isReasoningReplyPayload,
isNumericTargetId,
resolveOutboundMediaUrls,
resolveSendableOutboundReplyParts,
@@ -14,6 +15,22 @@ import {
sendPayloadWithChunkedTextAndMedia,
} from "./reply-payload.js";
describe("isReasoningReplyPayload", () => {
it.each([
{ name: "flagged", payload: { text: "Visible", isReasoning: true }, expected: true },
{ name: "prefix", payload: { text: " \n Reasoning:\n_hidden_" }, expected: true },
{ name: "blockquote", payload: { text: "> Reasoning:\n> _hidden_" }, expected: true },
{
name: "mid-message mention",
payload: { text: "Intro\nReasoning: visible discussion" },
expected: false,
},
{ name: "missing text", payload: {}, expected: false },
])("$name", ({ payload, expected }) => {
expect(isReasoningReplyPayload(payload)).toBe(expected);
});
});
describe("sendPayloadWithChunkedTextAndMedia", () => {
it("returns empty result when payload has no text and no media", async () => {
const result = await sendPayloadWithChunkedTextAndMedia({

View File

@@ -1,5 +1,5 @@
import type { ChannelOutboundAdapter } from "../channels/plugins/outbound.types.js";
import { readStringValue } from "../shared/string-coerce.js";
import { normalizeLowercaseStringOrEmpty, readStringValue } from "../shared/string-coerce.js";
export type { MediaPayload, MediaPayloadInput } from "../channels/plugins/media-payload.js";
export { buildMediaPayload } from "../channels/plugins/media-payload.js";
@@ -12,6 +12,11 @@ export type OutboundReplyPayload = {
replyToId?: string;
};
export type ReasoningReplyPayload = {
text?: string;
isReasoning?: boolean;
};
export type SendableOutboundReplyParts = {
text: string;
trimmedText: string;
@@ -29,6 +34,33 @@ type SendPayloadAdapter = Pick<
"sendMedia" | "sendText" | "chunker" | "textChunkLimit"
>;
const REASONING_PREFIX = "reasoning:";
function trimLeadingMarkdownQuoteMarkers(text: string): string {
let candidate = text.trimStart();
while (candidate.startsWith(">")) {
candidate = candidate.replace(/^(?:>[ \t]?)+/, "").trimStart();
}
return candidate;
}
export function isReasoningReplyPayload(payload: ReasoningReplyPayload): boolean {
if (payload.isReasoning === true) {
return true;
}
const text = payload.text;
if (typeof text !== "string") {
return false;
}
const normalized = normalizeLowercaseStringOrEmpty(text.trimStart());
if (normalized.startsWith(REASONING_PREFIX)) {
return true;
}
return normalizeLowercaseStringOrEmpty(trimLeadingMarkdownQuoteMarkers(text)).startsWith(
REASONING_PREFIX,
);
}
/** Extract the supported outbound reply fields from loose tool or agent payload objects. */
export function normalizeOutboundReplyPayload(
payload: Record<string, unknown>,