From 6b5f0115c6172f4184d33ea2ef8d2f7a2ea03672 Mon Sep 17 00:00:00 2001 From: Ruben Cuevas Date: Sat, 25 Apr 2026 19:38:52 -0400 Subject: [PATCH] fix(whatsapp): sanitize XML tool text and suppress errors --- docs/.generated/config-baseline.sha256 | 8 +- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/channels/whatsapp.md | 18 ++- extensions/whatsapp/src/accounts.ts | 2 + .../src/auto-reply/deliver-reply.test.ts | 46 ++++++++ extensions/whatsapp/src/auto-reply/monitor.ts | 1 + .../monitor/inbound-dispatch.test.ts | 104 +++++++++++++++++ .../auto-reply/monitor/inbound-dispatch.ts | 36 +++++- .../whatsapp/src/channel-outbound.test.ts | 46 ++++++++ extensions/whatsapp/src/channel-outbound.ts | 4 +- extensions/whatsapp/src/config-schema.test.ts | 14 +++ extensions/whatsapp/src/config-ui-hints.ts | 4 + .../src/outbound-adapter.sendpayload.test.ts | 44 +++++++ extensions/whatsapp/src/outbound-adapter.ts | 9 +- extensions/whatsapp/src/outbound-base.ts | 10 +- .../whatsapp/src/outbound-media-contract.ts | 18 ++- src/agents/pi-embedded-utils.test.ts | 15 +++ src/channels/plugins/outbound.types.ts | 1 + ...ndled-channel-config-metadata.generated.ts | 10 ++ src/config/types.whatsapp.ts | 2 + .../zod-schema.providers-whatsapp.test.ts | 15 +++ src/config/zod-schema.providers-whatsapp.ts | 1 + src/infra/outbound/deliver.test.ts | 107 ++++++++++++++++++ src/infra/outbound/deliver.ts | 29 ++++- .../text/assistant-visible-text.test.ts | 48 ++++++++ src/shared/text/assistant-visible-text.ts | 56 +++++++-- 26 files changed, 615 insertions(+), 37 deletions(-) diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 6e36959297e..44660d2373f 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -7f9815a297504c75022c4db2df250ce4cc9ff5c3f69250c67ca253b89148b9f3 config-baseline.json -8bc9fda7c1096472beaa416a61043ce51d691d4dcad9ed3e0be46e68bb70b0ce config-baseline.core.json -45162ff84813be8a1fe561ed8d6245a248d5c6288ef9e9af51bdf4ec05ef65ad config-baseline.channel.json -0dd6583fafae6c9134e46c4cf9bddee9822d6436436dcb1a6dcba6d012962e51 config-baseline.plugin.json +8903fd57a58acb9a7c949efc6b4197b249220dcd965420ceb7d884cb45fbc48d config-baseline.json +86ad0927d992bc873affb3e20a31c6e3c95b2185a91f46cc8e6262a723a78f7d config-baseline.core.json +bb7234c52b0bbf12de2a87fa553ec4e89e13aaba9d0d81cf1370621292da13e9 config-baseline.channel.json +1f5592bfd141ba1e982ce31763a253c10afb080ab4ea2b6538299b114e29cee1 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 6802c7ccf84..dc4ecfcac87 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -e7d03a0d5aed4f1afb5c7d5e235a166e1e248090632248eaa92b0016531e7f3b plugin-sdk-api-baseline.json -b9bbf8e444b358485cb33c634d3f6f6588004a5c32482c1a473167957269ae58 plugin-sdk-api-baseline.jsonl +c65b1aa1fb4cf402b90bedd3614eb5d7c3903ab860856392d1ee2481818a7a22 plugin-sdk-api-baseline.json +da172742470204044c1542a3bba7f183161e90e742f6865c1c7f822dbdc7a7d6 plugin-sdk-api-baseline.jsonl diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index da9835ed12c..59022be259f 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -393,6 +393,22 @@ When the linked self number is also present in `allowFrom`, WhatsApp self-chat s +## Error visibility + +`channels.whatsapp.exposeErrorText` controls whether agent/provider error text is delivered back into WhatsApp. The default is `true`. Set it to `false` to keep failures quiet on WhatsApp while preserving other channel behavior. + +```json5 +{ + channels: { + whatsapp: { + exposeErrorText: false, + }, + }, +} +``` + +Per-account overrides use `channels.whatsapp.accounts..exposeErrorText`. + ## Reply quoting WhatsApp supports native reply quoting, where outbound replies visibly quote the inbound message. Control it with `channels.whatsapp.replyToMode`. @@ -660,7 +676,7 @@ Primary reference: High-signal WhatsApp fields: - access: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups` -- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction`, `reactionLevel` +- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction`, `reactionLevel`, `exposeErrorText` - multi-account: `accounts..enabled`, `accounts..authDir`, account-level overrides - operations: `configWrites`, `debounceMs`, `web.enabled`, `web.heartbeatSeconds`, `web.reconnect.*`, `web.whatsapp.*` - session behavior: `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms..historyLimit` diff --git a/extensions/whatsapp/src/accounts.ts b/extensions/whatsapp/src/accounts.ts index e6c8101f725..1e10209ab17 100644 --- a/extensions/whatsapp/src/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -45,6 +45,7 @@ export type ResolvedWhatsAppAccount = { direct?: WhatsAppAccountConfig["direct"]; debounceMs?: number; replyToMode?: ReplyToMode; + exposeErrorText?: boolean; }; export const DEFAULT_WHATSAPP_MEDIA_MAX_MB = 50; @@ -156,6 +157,7 @@ export function resolveWhatsAppAccount(params: { direct: merged.direct, debounceMs: merged.debounceMs, replyToMode: merged.replyToMode, + exposeErrorText: merged.exposeErrorText, }; } diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts index 5bb4c62f2bf..c8bf85f3d2e 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts @@ -177,6 +177,28 @@ describe("deliverWebReply", () => { expect(replyLogger.info).toHaveBeenCalledWith(expect.any(Object), "auto-reply sent (text)"); }); + it("strips raw XML tool-call blocks before WhatsApp text delivery", async () => { + const msg = makeMsg(); + + await deliverWebReply({ + replyResult: { + text: 'Before\nx\nAfter', + }, + msg, + maxMediaBytes: 1024 * 1024, + textLimit: 4000, + replyLogger, + skipLog: true, + }); + + expect(msg.reply).toHaveBeenCalledTimes(1); + const sentText = vi.mocked(msg.reply).mock.calls[0]?.[0]; + expect(sentText).not.toContain("function_calls"); + expect(sentText).not.toContain("invoke"); + expect(sentText).toContain("Before"); + expect(sentText).toContain("After"); + }); + it("keeps quote threading on every text chunk for a threaded reply", async () => { const msg = makeMsg(); cacheInboundMessageMeta("work", "15551234567@s.whatsapp.net", "reply-1", { @@ -478,6 +500,30 @@ describe("deliverWebReply", () => { ).not.toContain("boom"); }); + it("sanitizes XML tool-call blocks for outbound sendPayload delivery", async () => { + const sendWhatsApp = vi.fn(async (_to: string, _text: string) => ({ + messageId: "wa-1", + toJid: "jid", + })); + + await whatsappOutbound.sendPayload!({ + cfg: {}, + to: "5511999999999@c.us", + text: "", + payload: { + text: 'Before\nx\nAfter', + }, + deps: { sendWhatsApp }, + }); + + expect(sendWhatsApp).toHaveBeenCalledTimes(1); + const sentText = sendWhatsApp.mock.calls[0]?.[1]; + expect(sentText).not.toContain("function_calls"); + expect(sentText).not.toContain("invoke"); + expect(sentText).toContain("Before"); + expect(sentText).toContain("After"); + }); + it("keeps payload and auto-reply media normalization in parity", async () => { const payload = { text: "\n\ncaption", diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index f4a2f3a4674..662b793280f 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -176,6 +176,7 @@ export async function monitorWebChannel( mediaMaxMb: account.mediaMaxMb, blockStreaming: account.blockStreaming, groups: account.groups, + exposeErrorText: account.exposeErrorText, }, }, } satisfies ReturnType; diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts index 50ee424ec39..5095ca0ce24 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -6,6 +6,7 @@ type CapturedReplyPayload = { text?: string; isReasoning?: boolean; isCompactionNotice?: boolean; + isError?: boolean; mediaUrl?: string; mediaUrls?: string[]; }; @@ -434,6 +435,39 @@ describe("whatsapp inbound dispatch", () => { expect(rememberSentText).toHaveBeenCalledTimes(4); }); + it("normalizes WhatsApp payload text before delivery and echo bookkeeping", async () => { + const deliverReply = vi.fn(async () => undefined); + const rememberSentText = vi.fn(); + + await dispatchBufferedReply({ + deliverReply, + rememberSentText, + }); + + const deliver = getCapturedDeliver(); + expect(deliver).toBeTypeOf("function"); + + await deliver?.( + { + text: 'Before\nx\nAfter', + }, + { kind: "final" }, + ); + + expect(deliverReply).toHaveBeenCalledWith( + expect.objectContaining({ + replyResult: expect.objectContaining({ text: "Before\n\nAfter" }), + }), + ); + expect(rememberSentText).toHaveBeenCalledWith( + "Before\n\nAfter", + expect.objectContaining({ + combinedBody: "hi", + combinedBodySessionKey: "agent:main:whatsapp:direct:+1000", + }), + ); + }); + it("suppresses reasoning and compaction payloads before WhatsApp delivery", async () => { const deliverReply = vi.fn(async () => undefined); const rememberSentText = vi.fn(); @@ -455,6 +489,76 @@ describe("whatsapp inbound dispatch", () => { expect(rememberSentText).not.toHaveBeenCalled(); }); + it("suppresses payloads that normalize to no visible WhatsApp content", async () => { + const deliverReply = vi.fn(async () => undefined); + const rememberSentText = vi.fn(); + + await dispatchBufferedReply({ + deliverReply, + rememberSentText, + }); + + const deliver = getCapturedDeliver(); + expect(deliver).toBeTypeOf("function"); + + await deliver?.( + { + text: 'x', + }, + { kind: "final" }, + ); + + expect(deliverReply).not.toHaveBeenCalled(); + expect(rememberSentText).not.toHaveBeenCalled(); + }); + + it("suppresses error payload text when exposeErrorText is false", async () => { + const deliverReply = vi.fn(async () => undefined); + const rememberSentText = vi.fn(); + + await dispatchBufferedReply({ + cfg: { channels: { whatsapp: { exposeErrorText: false } } } as never, + deliverReply, + rememberSentText, + }); + + const deliver = getCapturedDeliver(); + expect(deliver).toBeTypeOf("function"); + + await deliver?.({ text: "provider exploded", isError: true }, { kind: "final" }); + + expect(deliverReply).not.toHaveBeenCalled(); + expect(rememberSentText).not.toHaveBeenCalled(); + }); + + it("honors account-level exposeErrorText overrides for error payloads", async () => { + const deliverReply = vi.fn(async () => undefined); + const rememberSentText = vi.fn(); + + await dispatchBufferedReply({ + cfg: { + channels: { + whatsapp: { + accounts: { + work: { exposeErrorText: false }, + }, + }, + }, + } as never, + deliverReply, + rememberSentText, + route: makeRoute({ accountId: "work" }), + }); + + const deliver = getCapturedDeliver(); + expect(deliver).toBeTypeOf("function"); + + await deliver?.({ text: "provider exploded", isError: true }, { kind: "final" }); + + expect(deliverReply).not.toHaveBeenCalled(); + expect(rememberSentText).not.toHaveBeenCalled(); + }); + it("maps WhatsApp blockStreaming=true to disableBlockStreaming=false", async () => { await dispatchBufferedReply(); diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index 971d8cedd75..429933945cc 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -1,3 +1,8 @@ +import { resolveMergedWhatsAppAccountConfig } from "../../account-config.js"; +import { + normalizeWhatsAppOutboundPayload, + normalizeWhatsAppPayloadTextPreservingIndentation, +} from "../../outbound-media-contract.js"; import type { WebInboundMsg } from "../types.js"; import { formatGroupMembers } from "./group-members.js"; import type { GroupHistoryEntry } from "./inbound-context.js"; @@ -60,10 +65,14 @@ function resolveWhatsAppDisableBlockStreaming(cfg: ReturnType): bo function resolveWhatsAppDeliverablePayload( payload: ReplyPayload, info: { kind: ReplyLifecycleKind }, + options?: { exposeErrorText?: boolean }, ): ReplyPayload | null { if (payload.isReasoning === true || payload.isCompactionNotice === true) { return null; } + if (payload.isError === true && options?.exposeErrorText === false) { + return null; + } if (info.kind === "tool") { if (!resolveSendableOutboundReplyParts(payload).hasMedia) { return null; @@ -280,6 +289,9 @@ export async function dispatchWhatsAppBufferedReply(params: { }); const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.route.agentId); const disableBlockStreaming = resolveWhatsAppDisableBlockStreaming(params.cfg); + const exposeErrorText = + resolveMergedWhatsAppAccountConfig({ cfg: params.cfg, accountId: params.route.accountId }) + .exposeErrorText !== false; let didSendReply = false; let didLogHeartbeatStrip = false; @@ -296,12 +308,25 @@ export async function dispatchWhatsAppBufferedReply(params: { } }, deliver: async (payload: ReplyPayload, info: { kind: ReplyLifecycleKind }) => { - const deliveryPayload = resolveWhatsAppDeliverablePayload(payload, info); + const deliveryPayload = resolveWhatsAppDeliverablePayload(payload, info, { + exposeErrorText, + }); if (!deliveryPayload) { return; } + const normalizedOutboundPayload = normalizeWhatsAppOutboundPayload(deliveryPayload, { + normalizeText: normalizeWhatsAppPayloadTextPreservingIndentation, + }); + const normalizedDeliveryPayload = + deliveryPayload.text === undefined + ? { ...normalizedOutboundPayload, text: undefined } + : normalizedOutboundPayload; + const reply = resolveSendableOutboundReplyParts(normalizedDeliveryPayload); + if (!reply.hasMedia && !reply.text.trim()) { + return; + } await params.deliverReply({ - replyResult: deliveryPayload, + replyResult: normalizedDeliveryPayload, msg: params.msg, mediaLocalRoots, maxMediaBytes: params.maxMediaBytes, @@ -313,17 +338,16 @@ export async function dispatchWhatsAppBufferedReply(params: { tableMode, }); didSendReply = true; - const shouldLog = deliveryPayload.text ? true : undefined; - params.rememberSentText(deliveryPayload.text, { + const shouldLog = normalizedDeliveryPayload.text ? true : undefined; + params.rememberSentText(normalizedDeliveryPayload.text, { combinedBody: params.context.Body as string | undefined, combinedBodySessionKey: params.route.sessionKey, logVerboseMessage: shouldLog, }); const fromDisplay = params.msg.chatType === "group" ? params.conversationId : (params.msg.from ?? "unknown"); - const reply = resolveSendableOutboundReplyParts(deliveryPayload); if (shouldLogVerbose()) { - const preview = deliveryPayload.text != null ? reply.text : ""; + const preview = normalizedDeliveryPayload.text != null ? reply.text : ""; logVerbose(`Reply body: ${preview}${reply.hasMedia ? " (media)" : ""} -> ${fromDisplay}`); } }, diff --git a/extensions/whatsapp/src/channel-outbound.test.ts b/extensions/whatsapp/src/channel-outbound.test.ts index a6392a01b41..1d74ca6c45b 100644 --- a/extensions/whatsapp/src/channel-outbound.test.ts +++ b/extensions/whatsapp/src/channel-outbound.test.ts @@ -39,6 +39,52 @@ describe("whatsappChannelOutbound", () => { }); }); + it("keeps XML sanitizer normalization idempotent", () => { + const raw = [ + "", + ' ', + ' hidden', + " ", + "", + "After", + ].join("\n"); + const once = whatsappChannelOutbound.normalizePayload?.({ payload: { text: raw } }); + const twice = whatsappChannelOutbound.normalizePayload?.({ payload: { text: once?.text } }); + + expect(once?.text).toBe("After"); + expect(twice?.text).toBe("After"); + }); + + it("drops whitespace-only text after XML sanitizer removal", () => { + const raw = [ + " ", + ' ', + ' hidden', + " ", + " ", + ].join("\n"); + + expect(whatsappChannelOutbound.normalizePayload?.({ payload: { text: raw } })).toEqual({ + text: "", + }); + }); + + it("sanitizes XML tool payloads before plain HTML stripping", () => { + const raw = [ + "Before", + "", + ' ', + ' hidden', + " ", + "", + "After", + ].join("\n"); + + expect(whatsappChannelOutbound.sanitizeText?.({ text: raw, payload: { text: raw } })).toBe( + "Before\n\nAfter", + ); + }); + it("preserves indentation for live text sends", async () => { await whatsappChannelOutbound.sendText!({ cfg: {}, diff --git a/extensions/whatsapp/src/channel-outbound.ts b/extensions/whatsapp/src/channel-outbound.ts index ca80f2a29ac..55bbaa782f6 100644 --- a/extensions/whatsapp/src/channel-outbound.ts +++ b/extensions/whatsapp/src/channel-outbound.ts @@ -1,11 +1,12 @@ import { chunkText } from "openclaw/plugin-sdk/reply-chunking"; import { createWhatsAppOutboundBase } from "./outbound-base.js"; +import { normalizeWhatsAppPayloadTextPreservingIndentation } from "./outbound-media-contract.js"; import { resolveWhatsAppOutboundTarget } from "./resolve-outbound-target.js"; import { getWhatsAppRuntime } from "./runtime.js"; import { sendMessageWhatsApp, sendPollWhatsApp } from "./send.js"; export function normalizeWhatsAppChannelPayloadText(text: string | undefined): string { - return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, ""); + return normalizeWhatsAppPayloadTextPreservingIndentation(text); } function normalizeWhatsAppChannelSendText(text: string | undefined): string { @@ -27,6 +28,7 @@ export const whatsappChannelOutbound = { resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), normalizeText: normalizeWhatsAppChannelSendText, }), + sendTextOnlyErrorPayloads: true, normalizePayload: ({ payload }: { payload: { text?: string } }) => ({ ...payload, text: normalizeWhatsAppChannelPayloadText(payload.text), diff --git a/extensions/whatsapp/src/config-schema.test.ts b/extensions/whatsapp/src/config-schema.test.ts index ce1e089866e..9bedf6cd7ee 100644 --- a/extensions/whatsapp/src/config-schema.test.ts +++ b/extensions/whatsapp/src/config-schema.test.ts @@ -63,6 +63,20 @@ describe("whatsapp config schema", () => { } }); + it("accepts exposeErrorText at channel and account scope", () => { + const res = expectWhatsAppConfigValid({ + exposeErrorText: false, + accounts: { + work: { exposeErrorText: true }, + }, + }); + + if (res.success) { + expect(res.data.exposeErrorText).toBe(false); + expect(res.data.accounts?.work?.exposeErrorText).toBe(true); + } + }); + it("accepts enabled", () => { expectWhatsAppConfigValid({ enabled: true, diff --git a/extensions/whatsapp/src/config-ui-hints.ts b/extensions/whatsapp/src/config-ui-hints.ts index c32d14a1d79..a891cec3ee2 100644 --- a/extensions/whatsapp/src/config-ui-hints.ts +++ b/extensions/whatsapp/src/config-ui-hints.ts @@ -21,4 +21,8 @@ export const whatsAppChannelConfigUiHints = { label: "WhatsApp Config Writes", help: "Allow WhatsApp to write config in response to channel events/commands (default: true).", }, + exposeErrorText: { + label: "WhatsApp Error Text", + help: "Deliver user-visible agent/provider error text into WhatsApp (default: true). Disable to keep failures quiet on WhatsApp.", + }, } satisfies Record; diff --git a/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts index 1f568237f74..9165e9e32bd 100644 --- a/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts +++ b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts @@ -137,6 +137,50 @@ describe("whatsappOutbound sendPayload", () => { expect(sendWhatsApp).not.toHaveBeenCalled(); }); + it("suppresses routed error payloads when error text is hidden", async () => { + const sendWhatsApp = vi.fn(); + + const result = await whatsappOutbound.sendPayload!({ + cfg: { channels: { whatsapp: { exposeErrorText: false } } }, + to: "5511999999999@c.us", + text: "", + payload: { text: "provider exploded", isError: true }, + deps: { sendWhatsApp }, + }); + + expect(result).toEqual({ channel: "whatsapp", messageId: "" }); + expect(sendWhatsApp).not.toHaveBeenCalled(); + }); + + it("uses account-level error text visibility for routed payloads", async () => { + const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" })); + + await whatsappOutbound.sendPayload!({ + cfg: { + channels: { + whatsapp: { + exposeErrorText: false, + accounts: { + work: { exposeErrorText: true }, + }, + }, + }, + }, + accountId: "work", + to: "5511999999999@c.us", + text: "", + payload: { text: "provider exploded", isError: true }, + deps: { sendWhatsApp }, + }); + + expect(sendWhatsApp).toHaveBeenCalledTimes(1); + expect(sendWhatsApp).toHaveBeenCalledWith( + "5511999999999@c.us", + "provider exploded", + expect.any(Object), + ); + }); + it("sanitizes HTML-only text to whitespace-only payload", () => { expect( whatsappOutbound diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts index df56baf527b..e4811db84c7 100644 --- a/extensions/whatsapp/src/outbound-adapter.ts +++ b/extensions/whatsapp/src/outbound-adapter.ts @@ -2,6 +2,7 @@ import { type ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-re import { chunkText } from "openclaw/plugin-sdk/reply-chunking"; import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { createWhatsAppOutboundBase } from "./outbound-base.js"; +import { normalizeWhatsAppPayloadText } from "./outbound-media-contract.js"; import { resolveWhatsAppOutboundTarget } from "./resolve-outbound-target.js"; type WhatsAppSendModule = typeof import("./send.js"); @@ -13,8 +14,8 @@ function loadWhatsAppSendModule(): Promise { return whatsAppSendModulePromise; } -function trimLeadingWhitespace(text: string | undefined): string { - return text?.trimStart() ?? ""; +function normalizeOutboundText(text: string | undefined): string { + return normalizeWhatsAppPayloadText(text); } export const whatsappOutbound: ChannelOutboundAdapter = createWhatsAppOutboundBase({ @@ -22,7 +23,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = createWhatsAppOutboundBa sendMessageWhatsApp: async (to, text, options) => await ( await loadWhatsAppSendModule() - ).sendMessageWhatsApp(to, trimLeadingWhitespace(text), { + ).sendMessageWhatsApp(to, normalizeOutboundText(text), { ...options, }), sendPollWhatsApp: async (to, poll, options) => @@ -30,6 +31,6 @@ export const whatsappOutbound: ChannelOutboundAdapter = createWhatsAppOutboundBa shouldLogVerbose: () => shouldLogVerbose(), resolveTarget: ({ to, allowFrom, mode }) => resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), - normalizeText: trimLeadingWhitespace, + normalizeText: normalizeOutboundText, skipEmptyText: true, }); diff --git a/extensions/whatsapp/src/outbound-base.ts b/extensions/whatsapp/src/outbound-base.ts index 3941b9063a9..e429b946f0a 100644 --- a/extensions/whatsapp/src/outbound-base.ts +++ b/extensions/whatsapp/src/outbound-base.ts @@ -12,6 +12,7 @@ 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"; import { normalizeWhatsAppOutboundPayload, normalizeWhatsAppPayloadText, @@ -144,7 +145,7 @@ export function createWhatsAppOutboundBase({ chunker, chunkerMode: "text", textChunkLimit: 4000, - sanitizeText: ({ text }) => sanitizeForPlainText(text), + sanitizeText: ({ text }) => sanitizeForPlainText(normalizeText(text)), pollMaxOptions: 12, resolveTarget, ...createAttachedChannelResultAdapter({ @@ -220,6 +221,13 @@ export function createWhatsAppOutboundBase({ return { ...outbound, sendPayload: async (ctx) => { + if ( + ctx.payload.isError === true && + resolveMergedWhatsAppAccountConfig({ cfg: ctx.cfg, accountId: ctx.accountId }) + .exposeErrorText === false + ) { + return { channel: "whatsapp", messageId: "" }; + } const payload = normalizeWhatsAppOutboundPayload(ctx.payload, { normalizeText }); if (!payload.text && !(payload.mediaUrl || payload.mediaUrls?.length)) { if (ctx.payload.interactive || ctx.payload.presentation || ctx.payload.channelData) { diff --git a/extensions/whatsapp/src/outbound-media-contract.ts b/extensions/whatsapp/src/outbound-media-contract.ts index e341e9d924a..f2227d4ad5b 100644 --- a/extensions/whatsapp/src/outbound-media-contract.ts +++ b/extensions/whatsapp/src/outbound-media-contract.ts @@ -3,7 +3,11 @@ import path from "node:path"; import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { formatError } from "./session-errors.js"; -import { sleep } from "./text-runtime.js"; +import { + sanitizeAssistantVisibleText, + sanitizeAssistantVisibleTextWithOptions, + sleep, +} from "./text-runtime.js"; type WhatsAppOutboundPayloadLike = { text?: string; @@ -31,13 +35,21 @@ const WHATSAPP_VOICE_BITRATE = "64k"; const WHATSAPP_VOICE_MIMETYPE = "audio/ogg; codecs=opus"; export function normalizeWhatsAppPayloadText(text: string | undefined): string { - return text?.trimStart() ?? ""; + return sanitizeAssistantVisibleText(text?.trimStart() ?? ""); +} + +function stripLeadingBlankLines(text: string): string { + return text.replace(/^(?:[ \t]*\r?\n)+/, ""); } export function normalizeWhatsAppPayloadTextPreservingIndentation( text: string | undefined, ): string { - return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, ""); + const sanitized = sanitizeAssistantVisibleTextWithOptions(stripLeadingBlankLines(text ?? ""), { + trim: "none", + }); + const normalized = stripLeadingBlankLines(sanitized); + return normalized.trim() ? normalized : ""; } export function resolveWhatsAppOutboundMediaUrls( diff --git a/src/agents/pi-embedded-utils.test.ts b/src/agents/pi-embedded-utils.test.ts index 45185f6c4a1..7b2fc6ed7c2 100644 --- a/src/agents/pi-embedded-utils.test.ts +++ b/src/agents/pi-embedded-utils.test.ts @@ -468,6 +468,21 @@ File contents here`, expect(extractAssistantText(msg)).toBe("Prefix\n\nSuffix"); }); + it("strips XML-style function_calls blocks from assistant text", () => { + const msg = makeAssistantMessage({ + role: "assistant", + content: [ + { + type: "text", + text: 'Before\nx\nAfter', + }, + ], + timestamp: Date.now(), + }); + + expect(extractAssistantText(msg)).toBe("Before\n\nAfter"); + }); + it("strips dangling XML content to end-of-string", () => { const msg = makeAssistantMessage({ role: "assistant", diff --git a/src/channels/plugins/outbound.types.ts b/src/channels/plugins/outbound.types.ts index 75c381848b1..9237f9a1cc4 100644 --- a/src/channels/plugins/outbound.types.ts +++ b/src/channels/plugins/outbound.types.ts @@ -84,6 +84,7 @@ export type ChannelOutboundAdapter = { supportsPollDurationSeconds?: boolean; supportsAnonymousPolls?: boolean; normalizePayload?: (params: { payload: ReplyPayload }) => ReplyPayload | null; + sendTextOnlyErrorPayloads?: boolean; shouldSkipPlainTextSanitization?: (params: { payload: ReplyPayload }) => boolean; resolveEffectiveTextChunkLimit?: (params: { cfg: OpenClawConfig; diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index af0dc873ba1..14af30b4518 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -15966,6 +15966,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ }, ], }, + exposeErrorText: { + type: "boolean", + }, heartbeat: { type: "object", properties: { @@ -16254,6 +16257,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ }, ], }, + exposeErrorText: { + type: "boolean", + }, heartbeat: { type: "object", properties: { @@ -16342,6 +16348,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ label: "WhatsApp Config Writes", help: "Allow WhatsApp to write config in response to channel events/commands (default: true).", }, + exposeErrorText: { + label: "WhatsApp Error Text", + help: "Deliver user-visible agent/provider error text into WhatsApp (default: true). Disable to keep failures quiet on WhatsApp.", + }, }, unsupportedSecretRefSurfacePatterns: [ "channels.whatsapp.accounts.*.creds.json", diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 0d52fee9dea..8e7d7fec5aa 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -105,6 +105,8 @@ type WhatsAppSharedConfig = { debounceMs?: number; /** Reply threading mode for auto-replies (off|first|all|batched). */ replyToMode?: ReplyToMode; + /** Whether WhatsApp should deliver user-visible error text. Default: true. */ + exposeErrorText?: boolean; /** Heartbeat visibility settings. */ heartbeat?: ChannelHeartbeatVisibilityConfig; /** Channel health monitor overrides for this channel/account. */ diff --git a/src/config/zod-schema.providers-whatsapp.test.ts b/src/config/zod-schema.providers-whatsapp.test.ts index d45b9a93378..0b3d693d1b8 100644 --- a/src/config/zod-schema.providers-whatsapp.test.ts +++ b/src/config/zod-schema.providers-whatsapp.test.ts @@ -72,6 +72,21 @@ describe("WhatsApp prompt config Zod validation", () => { } }); + it("validates exposeErrorText at root and account scope", () => { + const result = WhatsAppConfigSchema.safeParse({ + exposeErrorText: false, + accounts: { + work: { exposeErrorText: true }, + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.exposeErrorText).toBe(false); + expect(result.data.accounts?.work?.exposeErrorText).toBe(true); + } + }); + it("validates WhatsAppAccountSchema directly", () => { const accountConfig = { name: "Personal Account", diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index f1b1fb0516b..a859ee0c9d8 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -83,6 +83,7 @@ function buildWhatsAppCommonShape(params: { useDefaults: boolean }) { ? z.number().int().nonnegative().optional().default(0) : z.number().int().nonnegative().optional(), replyToMode: ReplyToModeSchema.optional(), + exposeErrorText: z.boolean().optional(), heartbeat: ChannelHeartbeatVisibilitySchema, healthMonitor: ChannelHealthMonitorSchema, }; diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 0f7c3af55d8..60abf0a418e 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -1797,6 +1797,113 @@ describe("deliverOutboundPayloads", () => { expect(hookMocks.runner.runMessageSent).not.toHaveBeenCalled(); }); + it("keeps text-only error payloads on the normal text path by default", async () => { + const sendPayload = vi.fn(); + const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" }); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ + id: "matrix", + outbound: { deliveryMode: "direct", sendPayload, sendText }, + }), + }, + ]), + ); + + const results = await deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room:1", + payloads: [{ text: "provider exploded", isError: true }], + }); + + expect(results).toEqual([{ channel: "matrix", messageId: "mx-1" }]); + expect(sendText).toHaveBeenCalledWith(expect.objectContaining({ text: "provider exploded" })); + expect(sendPayload).not.toHaveBeenCalled(); + }); + + it("routes text-only error payloads through sendPayload when the adapter opts in", async () => { + const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" }); + const sendText = vi.fn(); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ + id: "matrix", + outbound: { + deliveryMode: "direct", + sendPayload, + sendText, + sendTextOnlyErrorPayloads: true, + }, + }), + }, + ]), + ); + + const results = await deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room:1", + payloads: [{ text: "provider exploded", isError: true }], + }); + + expect(results).toEqual([{ channel: "matrix", messageId: "mx-1" }]); + expect(sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ + text: "provider exploded", + payload: expect.objectContaining({ text: "provider exploded", isError: true }), + }), + ); + expect(sendText).not.toHaveBeenCalled(); + }); + + it("does not count no-op sendPayload results as delivered", async () => { + hookMocks.runner.hasHooks.mockReturnValue(true); + const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "" }); + const sendText = vi.fn(); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ + id: "matrix", + outbound: { + deliveryMode: "direct", + sendPayload, + sendText, + sendTextOnlyErrorPayloads: true, + }, + }), + }, + ]), + ); + + const results = await deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room:1", + payloads: [{ text: "provider exploded", isError: true }], + mirror: { + sessionKey: "agent:main:main", + agentId: "main", + text: "provider exploded", + }, + }); + + expect(results).toEqual([]); + expect(sendPayload).toHaveBeenCalledTimes(1); + expect(sendText).not.toHaveBeenCalled(); + expect(hookMocks.runner.runMessageSent).not.toHaveBeenCalled(); + expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled(); + }); + it("emits message_sent success for sendPayload deliveries", async () => { hookMocks.runner.hasHooks.mockReturnValue(true); const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" }); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 8cab39103e9..ab2f659a845 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -91,6 +91,7 @@ type ChannelHandler = { supportsMedia: boolean; sanitizeText?: (payload: ReplyPayload) => string; normalizePayload?: (payload: ReplyPayload) => ReplyPayload | null; + sendTextOnlyErrorPayloads?: boolean; renderPresentation?: (payload: ReplyPayload) => Promise; pinDeliveredMessage?: (params: { target: ChannelOutboundTargetRef; @@ -229,6 +230,7 @@ function createPluginHandler( normalizePayload: outbound.normalizePayload ? (payload) => outbound.normalizePayload!({ payload }) : undefined, + sendTextOnlyErrorPayloads: outbound.sendTextOnlyErrorPayloads === true, renderPresentation: outbound.renderPresentation ? async (payload) => { const presentation = normalizeMessagePresentation(payload.presentation); @@ -544,6 +546,18 @@ function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload { return summarizeOutboundPayloadForTransport(payload); } +function hasDeliveryResultIdentity(result: OutboundDeliveryResult): boolean { + return Boolean( + result.messageId || + result.chatId || + result.channelId || + result.roomId || + result.conversationId || + result.toJid || + result.pollId, + ); +} + function normalizeDeliveryPin(payload: ReplyPayload): ReplyPayloadDeliveryPin | undefined { const pin = payload.delivery?.pin; if (pin === true) { @@ -1107,17 +1121,22 @@ async function deliverOutboundPayloadsCore( const deliveryTarget = handler.buildTargetRef({ threadId: sendOverrides.threadId }); if ( handler.sendPayload && - (hasReplyPayloadContent({ - presentation: effectivePayload.presentation, - interactive: effectivePayload.interactive, - channelData: effectivePayload.channelData, - }) || + ((effectivePayload.isError === true && handler.sendTextOnlyErrorPayloads === true) || + hasReplyPayloadContent({ + presentation: effectivePayload.presentation, + interactive: effectivePayload.interactive, + channelData: effectivePayload.channelData, + }) || effectivePayload.audioAsVoice === true) ) { const delivery = await handler.sendPayload( effectivePayload, applySendReplyToConsumption(sendOverrides), ); + if (!hasDeliveryResultIdentity(delivery)) { + completeDeliveryDiagnostics(0); + continue; + } results.push(delivery); await maybePinDeliveredMessage({ handler, diff --git a/src/shared/text/assistant-visible-text.test.ts b/src/shared/text/assistant-visible-text.test.ts index c30deeb9688..938e0c5f706 100644 --- a/src/shared/text/assistant-visible-text.test.ts +++ b/src/shared/text/assistant-visible-text.test.ts @@ -529,6 +529,54 @@ 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 26eb77f9d68..9a62e8bd820 100644 --- a/src/shared/text/assistant-visible-text.ts +++ b/src/shared/text/assistant-visible-text.ts @@ -29,7 +29,8 @@ const TOOL_CALL_TAG_NAMES = new Set([ const TOOL_CALL_JSON_PAYLOAD_START_RE = /^(?:\s+[A-Za-z_:][-A-Za-z0-9_:.]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))*\s*(?:\r?\n\s*)?[[{]/; const TOOL_CALL_XML_PAYLOAD_START_RE = - /^\s*(?:\r?\n\s*)?<(?:function|invoke|parameters?|arguments?)\b/i; + /^\s*(?:\r?\n\s*)?<(?:function_call|tool_call|function|invoke|parameters?|arguments?)\b/i; +const NESTED_JSON_TOOL_CALL_PAYLOAD_START_RE = /^\s*(?:\r?\n\s*)?<(?:function_call|tool_call)\b/i; type ToolCallPayloadKind = "json" | "xml" | null; @@ -124,6 +125,27 @@ function detectToolCallPayloadKind(text: string, start: number): ToolCallPayload return null; } +function startsWithNestedJsonToolCallPayload(text: string, start: number): boolean { + if (!NESTED_JSON_TOOL_CALL_PAYLOAD_START_RE.test(text.slice(start))) { + return false; + } + let cursor = start; + while (cursor < text.length && /\s/.test(text[cursor])) { + cursor += 1; + } + const nestedTag = parseToolCallTagAt(text, cursor); + if ( + !nestedTag || + nestedTag.isClose || + nestedTag.isSelfClosing || + nestedTag.isTruncated || + (nestedTag.tagName !== "function_call" && nestedTag.tagName !== "tool_call") + ) { + return false; + } + return TOOL_CALL_JSON_PAYLOAD_START_RE.test(text.slice(nestedTag.end)); +} + function isLikelyStandaloneFunctionToolCall( text: string, tagStart: number, @@ -197,7 +219,10 @@ function parseToolCallTagAt(text: string, start: number): ParsedToolCallTag | nu }; } -export function stripToolCallXmlTags(text: string): string { +export function stripToolCallXmlTags( + text: string, + options: { stripFunctionCallsXmlPayloads?: boolean } = {}, +): string { if (!text || !TOOL_CALL_QUICK_RE.test(text)) { return text; } @@ -250,18 +275,24 @@ export function stripToolCallXmlTags(text: string): string { continue; } const payloadStart = tag.isTruncated ? tag.contentStart : tag.end; - const payloadKind = - tag.tagName === "tool_call" || tag.tagName === "function" - ? detectToolCallPayloadKind(text, payloadStart) - : TOOL_CALL_JSON_PAYLOAD_START_RE.test(text.slice(payloadStart)) - ? "json" - : null; + const shouldDetectXmlPayload = + tag.tagName === "tool_call" || + tag.tagName === "function" || + (options.stripFunctionCallsXmlPayloads === true && + (tag.tagName === "function_calls" || tag.tagName === "tool_calls")); + const payloadKind = shouldDetectXmlPayload + ? detectToolCallPayloadKind(text, payloadStart) + : TOOL_CALL_JSON_PAYLOAD_START_RE.test(text.slice(payloadStart)) + ? "json" + : null; const shouldStripStandaloneFunction = tag.tagName !== "function" || isLikelyStandaloneFunctionToolCall(text, idx, tag); if (!tag.isClose && payloadKind && shouldStripStandaloneFunction) { inToolCallBlock = true; toolCallBlockContentStart = tag.end; - toolCallBlockNeedsQuoteBalance = payloadKind === "json"; + toolCallBlockNeedsQuoteBalance = + payloadKind === "json" || + (payloadKind === "xml" && startsWithNestedJsonToolCallPayload(text, payloadStart)); toolCallBlockStart = idx; toolCallBlockTagName = tag.tagName; if (tag.isTruncated) { @@ -526,6 +557,7 @@ type AssistantVisibleTextPipelineOptions = { finalTrim: ReasoningTagTrim; preserveDowngradedToolText?: boolean; preserveMinimaxToolXml?: boolean; + stripFunctionCallsXmlPayloads?: boolean; reasoningMode: ReasoningTagMode; reasoningTrim: ReasoningTagTrim; stageOrder: "reasoning-first" | "reasoning-last"; @@ -540,12 +572,14 @@ 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", @@ -586,7 +620,9 @@ function applyAssistantVisibleTextStagePipeline( } cleaned = stripModelSpecialTokens(cleaned); cleaned = stripRelevantMemoriesTags(cleaned); - cleaned = stripToolCallXmlTags(cleaned); + cleaned = stripToolCallXmlTags(cleaned, { + stripFunctionCallsXmlPayloads: options.stripFunctionCallsXmlPayloads, + }); cleaned = stripPlainTextToolCallBlocks(cleaned); if (!options.preserveDowngradedToolText) { cleaned = stripDowngradedToolCallText(cleaned);