fix(whatsapp): sanitize XML tool text and suppress errors

This commit is contained in:
Ruben Cuevas
2026-04-25 19:38:52 -04:00
committed by Marcus Castro
parent 9e34fb9feb
commit 6b5f0115c6
26 changed files with 615 additions and 37 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -393,6 +393,22 @@ When the linked self number is also present in `allowFrom`, WhatsApp self-chat s
</Accordion>
</AccordionGroup>
## 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.<id>.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.<id>.enabled`, `accounts.<id>.authDir`, account-level overrides
- operations: `configWrites`, `debounceMs`, `web.enabled`, `web.heartbeatSeconds`, `web.reconnect.*`, `web.whatsapp.*`
- session behavior: `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms.<id>.historyLimit`

View File

@@ -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,
};
}

View File

@@ -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\n<function_calls><invoke name="web_search"><parameter name="query">x</parameter></invoke></function_calls>\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\n<function_calls><invoke name="web_search"><parameter name="query">x</parameter></invoke></function_calls>\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",

View File

@@ -176,6 +176,7 @@ export async function monitorWebChannel(
mediaMaxMb: account.mediaMaxMb,
blockStreaming: account.blockStreaming,
groups: account.groups,
exposeErrorText: account.exposeErrorText,
},
},
} satisfies ReturnType<typeof getRuntimeConfig>;

View File

@@ -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\n<function_calls><invoke name="web_search"><parameter name="query">x</parameter></invoke></function_calls>\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: '<function_calls><invoke name="web_search"><parameter name="query">x</parameter></invoke></function_calls>',
},
{ 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();

View File

@@ -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<LoadConfigFn>): 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 : "<media>";
const preview = normalizedDeliveryPayload.text != null ? reply.text : "<media>";
logVerbose(`Reply body: ${preview}${reply.hasMedia ? " (media)" : ""} -> ${fromDisplay}`);
}
},

View File

@@ -39,6 +39,52 @@ describe("whatsappChannelOutbound", () => {
});
});
it("keeps XML sanitizer normalization idempotent", () => {
const raw = [
"<function_calls>",
' <invoke name="send_message">',
' <parameter name="text">hidden</parameter>',
" </invoke>",
"</function_calls>",
"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 = [
" <function_calls>",
' <invoke name="send_message">',
' <parameter name="text">hidden</parameter>',
" </invoke>",
" </function_calls>",
].join("\n");
expect(whatsappChannelOutbound.normalizePayload?.({ payload: { text: raw } })).toEqual({
text: "",
});
});
it("sanitizes XML tool payloads before plain HTML stripping", () => {
const raw = [
"Before",
"<function_calls>",
' <invoke name="send_message">',
' <parameter name="text">hidden</parameter>',
" </invoke>",
"</function_calls>",
"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: {},

View File

@@ -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),

View File

@@ -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,

View File

@@ -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<string, ChannelConfigUiHint>;

View File

@@ -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

View File

@@ -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<WhatsAppSendModule> {
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,
});

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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\n<function_calls><invoke name="web_search"><parameter name="query">x</parameter></invoke></function_calls>\nAfter',
},
],
timestamp: Date.now(),
});
expect(extractAssistantText(msg)).toBe("Before\n\nAfter");
});
it("strips dangling <tool_call> XML content to end-of-string", () => {
const msg = makeAssistantMessage({
role: "assistant",

View File

@@ -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;

View File

@@ -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",

View File

@@ -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. */

View File

@@ -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",

View File

@@ -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,
};

View File

@@ -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" });

View File

@@ -91,6 +91,7 @@ type ChannelHandler = {
supportsMedia: boolean;
sanitizeText?: (payload: ReplyPayload) => string;
normalizePayload?: (payload: ReplyPayload) => ReplyPayload | null;
sendTextOnlyErrorPayloads?: boolean;
renderPresentation?: (payload: ReplyPayload) => Promise<ReplyPayload | null>;
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,

View File

@@ -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",
"<tool_calls>",
'<tool_call>{"name":"read","arguments":{"path":"/tmp/x"}}</tool_call>',
"</tool_calls>",
"After",
].join("\n");
expect(sanitizeAssistantVisibleText(input)).toBe("Before\n\nAfter");
});
it("strips plural function_calls wrappers with nested function_call blocks", () => {
const input = [
"Before",
"<function_calls>",
'<function_call>{"name":"read","arguments":{"path":"/tmp/x"}}</function_call>',
"</function_calls>",
"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",
"<function_calls>",
'<function_call>{"name":"read","arguments":{"html":"</function_calls> SECRET"}}</function_call>',
"</function_calls>",
"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",
"<tool_calls>",
'<tool_call>{"name":"read","arguments":{"html":"</tool_calls> SECRET"}}</tool_call>',
"</tool_calls>",
"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 </think> Visible answer")).toBe(
"Visible answer",

View File

@@ -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);