diff --git a/CHANGELOG.md b/CHANGELOG.md index 4765fea48f6..f40baa04cfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 0431cf58145..9c00682ffb5 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/extensions/mattermost/src/mattermost/monitor.test.ts b/extensions/mattermost/src/mattermost/monitor.test.ts index 27965ac0417..0529376b7ec 100644 --- a/extensions/mattermost/src/mattermost/monitor.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.test.ts @@ -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, diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 090a12ac889..648bddf245e 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -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 { - if (shouldSuppressMattermostReasoningReply(params.payload)) { + if (isReasoningReplyPayload(params.payload)) { return; } diff --git a/extensions/mattermost/src/mattermost/reply-delivery.test.ts b/extensions/mattermost/src/mattermost/reply-delivery.test.ts index e2ed60ef4ea..7714121c855 100644 --- a/extensions/mattermost/src/mattermost/reply-delivery.test.ts +++ b/extensions/mattermost/src/mattermost/reply-delivery.test.ts @@ -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; diff --git a/extensions/mattermost/src/mattermost/reply-delivery.ts b/extensions/mattermost/src/mattermost/reply-delivery.ts index 6fd10d8fd6c..02a66b5e88c 100644 --- a/extensions/mattermost/src/mattermost/reply-delivery.ts +++ b/extensions/mattermost/src/mattermost/reply-delivery.ts @@ -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; -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 { - if (shouldSuppressMattermostReasoningReply(params.payload)) { + if (isReasoningReplyPayload(params.payload)) { return; } const reply = resolveSendableOutboundReplyParts(params.payload, { diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts index 6db3349ecbe..179fa5b2b29 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts @@ -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(); diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.ts index 0c34c3584f1..363a003a21b 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.ts @@ -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; } diff --git a/src/plugin-sdk/reply-payload.test.ts b/src/plugin-sdk/reply-payload.test.ts index e63387dfbf8..3f6cb300020 100644 --- a/src/plugin-sdk/reply-payload.test.ts +++ b/src/plugin-sdk/reply-payload.test.ts @@ -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({ diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index d67902f89f0..ec564a583b7 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -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,