diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e2ac576582..73d1bebc7e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -99,6 +99,7 @@ Docs: https://docs.openclaw.ai
- Channels/WhatsApp: detect explicit group `@mentions` again when the bot's own E.164 is in `allowFrom`, so shared-number setups no longer skip group pings that directly mention the bot. Fixes #49317. (#73453) Thanks @juan-flores077.
- WhatsApp/reliability: publish real transport-liveness into WhatsApp channel status and force earlier reconnects on silent transport stalls, so quiet healthy sessions stay connected while wedged sockets recover before the later remote 408 path. (#72656) Thanks @Sathvik-1007.
- Core/channels: tighten selected runtime, media, and plugin edge-case handling while preserving existing behavior. Thanks @jesse-merhi.
+- Channels/WhatsApp: strip leaked plural tool-call XML wrappers on every WhatsApp-visible outbound path and allow `channels.whatsapp.exposeErrorText` to suppress visible error text per channel or account. (#71830) Thanks @rubencu.
## 2026.4.27
diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts
index c8bf85f3d2e..4fb754e1f71 100644
--- a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts
+++ b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts
@@ -199,6 +199,32 @@ describe("deliverWebReply", () => {
expect(sentText).toContain("After");
});
+ it("uses the same final sanitizer stack for auto-reply text delivery", async () => {
+ const msg = makeMsg();
+
+ await deliverWebReply({
+ replyResult: {
+ text: [
+ "Before",
+ "",
+ ' ',
+ ' hidden',
+ " ",
+ "",
+ "
After
",
+ ].join("\n"),
+ },
+ msg,
+ maxMediaBytes: 1024 * 1024,
+ textLimit: 4000,
+ replyLogger,
+ skipLog: true,
+ });
+
+ expect(msg.reply).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(msg.reply).mock.calls[0]?.[0]).toBe("Before\n\nAfter\n");
+ });
+
it("keeps quote threading on every text chunk for a threaded reply", async () => {
const msg = makeMsg();
cacheInboundMessageMeta("work", "15551234567@s.whatsapp.net", "reply-1", {
diff --git a/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts
index 9165e9e32bd..5e09de52122 100644
--- a/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts
+++ b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts
@@ -20,6 +20,36 @@ describe("whatsappOutbound sendPayload", () => {
});
});
+ it("uses the same final sanitizer stack for direct text sends", async () => {
+ const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
+
+ await whatsappOutbound.sendText!({
+ cfg: {},
+ to: "5511999999999@c.us",
+ text: [
+ "Before",
+ "",
+ ' ',
+ ' hidden',
+ " ",
+ "",
+ "After
",
+ ].join("\n"),
+ deps: { sendWhatsApp },
+ });
+
+ expect(sendWhatsApp).toHaveBeenCalledWith(
+ "5511999999999@c.us",
+ "Before\n\nAfter\n",
+ expect.objectContaining({
+ verbose: false,
+ cfg: {},
+ accountId: undefined,
+ gifPlayback: undefined,
+ }),
+ );
+ });
+
it("trims leading whitespace for direct media captions", async () => {
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
diff --git a/extensions/whatsapp/src/outbound-base.ts b/extensions/whatsapp/src/outbound-base.ts
index e429b946f0a..edeb8e43e4c 100644
--- a/extensions/whatsapp/src/outbound-base.ts
+++ b/extensions/whatsapp/src/outbound-base.ts
@@ -9,7 +9,6 @@ import {
type ChannelOutboundAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
-import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-send-deps";
import { sendTextMediaPayload } from "openclaw/plugin-sdk/reply-payload";
import { resolveMergedWhatsAppAccountConfig } from "./account-config.js";
@@ -145,7 +144,7 @@ export function createWhatsAppOutboundBase({
chunker,
chunkerMode: "text",
textChunkLimit: 4000,
- sanitizeText: ({ text }) => sanitizeForPlainText(normalizeText(text)),
+ sanitizeText: ({ text }) => normalizeText(text),
pollMaxOptions: 12,
resolveTarget,
...createAttachedChannelResultAdapter({
diff --git a/extensions/whatsapp/src/outbound-media-contract.ts b/extensions/whatsapp/src/outbound-media-contract.ts
index f2227d4ad5b..e01b2eb2d44 100644
--- a/extensions/whatsapp/src/outbound-media-contract.ts
+++ b/extensions/whatsapp/src/outbound-media-contract.ts
@@ -1,11 +1,13 @@
import fs from "node:fs/promises";
import path from "node:path";
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
+import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { formatError } from "./session-errors.js";
import {
sanitizeAssistantVisibleText,
- sanitizeAssistantVisibleTextWithOptions,
+ sanitizeAssistantVisibleTextWithProfile,
+ stripToolCallXmlTags,
sleep,
} from "./text-runtime.js";
@@ -34,8 +36,16 @@ const WHATSAPP_VOICE_SAMPLE_RATE_HZ = 48_000;
const WHATSAPP_VOICE_BITRATE = "64k";
const WHATSAPP_VOICE_MIMETYPE = "audio/ogg; codecs=opus";
+function stripWhatsAppPluralToolXml(text: string): string {
+ return stripToolCallXmlTags(text, { stripFunctionCallsXmlPayloads: true });
+}
+
+function finalizeWhatsAppVisibleText(text: string): string {
+ return sanitizeForPlainText(stripWhatsAppPluralToolXml(text));
+}
+
export function normalizeWhatsAppPayloadText(text: string | undefined): string {
- return sanitizeAssistantVisibleText(text?.trimStart() ?? "");
+ return finalizeWhatsAppVisibleText(sanitizeAssistantVisibleText(text ?? "")).trimStart();
}
function stripLeadingBlankLines(text: string): string {
@@ -45,10 +55,11 @@ function stripLeadingBlankLines(text: string): string {
export function normalizeWhatsAppPayloadTextPreservingIndentation(
text: string | undefined,
): string {
- const sanitized = sanitizeAssistantVisibleTextWithOptions(stripLeadingBlankLines(text ?? ""), {
- trim: "none",
- });
- const normalized = stripLeadingBlankLines(sanitized);
+ const sanitized = sanitizeAssistantVisibleTextWithProfile(
+ stripLeadingBlankLines(text ?? ""),
+ "history",
+ );
+ const normalized = stripLeadingBlankLines(finalizeWhatsAppVisibleText(sanitized));
return normalized.trim() ? normalized : "";
}
diff --git a/src/shared/text/assistant-visible-text.test.ts b/src/shared/text/assistant-visible-text.test.ts
index 938e0c5f706..c30deeb9688 100644
--- a/src/shared/text/assistant-visible-text.test.ts
+++ b/src/shared/text/assistant-visible-text.test.ts
@@ -529,54 +529,6 @@ describe("sanitizeAssistantVisibleText", () => {
expect(sanitizeAssistantVisibleText(input)).toBe("Visible answer");
});
- it("strips plural tool_calls wrappers with nested tool_call blocks", () => {
- const input = [
- "Before",
- "",
- '{"name":"read","arguments":{"path":"/tmp/x"}}',
- "",
- "After",
- ].join("\n");
-
- expect(sanitizeAssistantVisibleText(input)).toBe("Before\n\nAfter");
- });
-
- it("strips plural function_calls wrappers with nested function_call blocks", () => {
- const input = [
- "Before",
- "",
- '{"name":"read","arguments":{"path":"/tmp/x"}}',
- "",
- "After",
- ].join("\n");
-
- expect(sanitizeAssistantVisibleText(input)).toBe("Before\n\nAfter");
- });
-
- it("does not close plural function_calls wrappers on matching text inside nested JSON", () => {
- const input = [
- "Before",
- "",
- '{"name":"read","arguments":{"html":" SECRET"}}',
- "",
- "After",
- ].join("\n");
-
- expect(sanitizeAssistantVisibleText(input)).toBe("Before\n\nAfter");
- });
-
- it("does not close plural tool_calls wrappers on matching text inside nested JSON", () => {
- const input = [
- "Before",
- "",
- '{"name":"read","arguments":{"html":" SECRET"}}',
- "",
- "After",
- ].join("\n");
-
- expect(sanitizeAssistantVisibleText(input)).toBe("Before\n\nAfter");
- });
-
it("drops malformed reasoning before orphan close tags when final text follows", () => {
expect(sanitizeAssistantVisibleText("private chain of thought Visible answer")).toBe(
"Visible answer",
diff --git a/src/shared/text/assistant-visible-text.ts b/src/shared/text/assistant-visible-text.ts
index 9a62e8bd820..7d6822cb585 100644
--- a/src/shared/text/assistant-visible-text.ts
+++ b/src/shared/text/assistant-visible-text.ts
@@ -572,14 +572,12 @@ const ASSISTANT_VISIBLE_TEXT_PIPELINE_OPTIONS: Record<
reasoningMode: "strict",
reasoningTrim: "both",
stageOrder: "reasoning-last",
- stripFunctionCallsXmlPayloads: true,
},
history: {
finalTrim: "none",
reasoningMode: "strict",
reasoningTrim: "none",
stageOrder: "reasoning-last",
- stripFunctionCallsXmlPayloads: true,
},
"internal-scaffolding": {
finalTrim: "start",