Skip to content

Commit 2670402

Browse files
committed
fix: narrow WhatsApp tool media delivery
1 parent 7e33518 commit 2670402

3 files changed

Lines changed: 102 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ Docs: https://docs.openclaw.ai
193193
- Slack: route native stream fallback replies through the normal chunked sender so long buffered Slack Connect responses are not dropped or duplicated. (#71124) Thanks @martingarramon.
194194
- WhatsApp: transcribe accepted voice notes before agent dispatch while keeping spoken transcripts out of command authorization. (#64120) Thanks @rogerdigital.
195195
- Plugins/CLI: expose channel plugin CLI descriptors during discovery-mode plugin loads so snapshot registries keep channel commands visible without activating full runtimes. (#71309) Thanks @gumadeiras.
196+
- WhatsApp: deliver media generated by tool-result replies while still suppressing text-only tool chatter. (#60968) Thanks @adaclaw.
196197

197198
## 2026.4.23
198199

extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
22

33
let capturedDispatchParams: unknown;
44

5+
type CapturedReplyPayload = {
6+
text?: string;
7+
isReasoning?: boolean;
8+
isCompactionNotice?: boolean;
9+
mediaUrl?: string;
10+
mediaUrls?: string[];
11+
};
12+
513
const { dispatchReplyWithBufferedBlockDispatcherMock } = vi.hoisted(() => ({
614
dispatchReplyWithBufferedBlockDispatcherMock: vi.fn(async (params: { ctx: unknown }) => {
715
capturedDispatchParams = params;
@@ -101,7 +109,7 @@ function getCapturedDeliver() {
101109
capturedDispatchParams as {
102110
dispatcherOptions?: {
103111
deliver?: (
104-
payload: { text?: string; isReasoning?: boolean; isCompactionNotice?: boolean },
112+
payload: CapturedReplyPayload,
105113
info: { kind: "tool" | "block" | "final" },
106114
) => Promise<void>;
107115
};
@@ -386,11 +394,22 @@ describe("whatsapp inbound dispatch", () => {
386394
expect(deliverReply).not.toHaveBeenCalled();
387395
expect(rememberSentText).not.toHaveBeenCalled();
388396

389-
await deliver?.({ text: "tool image", mediaUrls: ["/tmp/generated.jpg"] } as never, {
390-
kind: "tool",
391-
});
397+
await deliver?.(
398+
{ text: "tool image", mediaUrls: ["/tmp/generated.jpg"] },
399+
{
400+
kind: "tool",
401+
},
402+
);
392403
expect(deliverReply).toHaveBeenCalledTimes(1);
393404
expect(rememberSentText).toHaveBeenCalledTimes(1);
405+
expect(deliverReply).toHaveBeenLastCalledWith(
406+
expect.objectContaining({
407+
replyResult: expect.objectContaining({
408+
mediaUrls: ["/tmp/generated.jpg"],
409+
text: undefined,
410+
}),
411+
}),
412+
);
394413

395414
await deliver?.({ text: "block payload" }, { kind: "block" });
396415
await deliver?.({ text: "final payload" }, { kind: "final" });
@@ -489,6 +508,65 @@ describe("whatsapp inbound dispatch", () => {
489508
expect(rememberSentText).toHaveBeenCalledTimes(1);
490509
});
491510

511+
it("returns true for tool-only media turns after delivering media", async () => {
512+
const deliverReply = vi.fn(async () => undefined);
513+
const rememberSentText = vi.fn();
514+
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce(
515+
async (params: {
516+
ctx: unknown;
517+
dispatcherOptions?: {
518+
deliver?: (
519+
payload: CapturedReplyPayload,
520+
info: { kind: "tool" | "block" | "final" },
521+
) => Promise<void>;
522+
};
523+
}) => {
524+
capturedDispatchParams = params;
525+
await params.dispatcherOptions?.deliver?.(
526+
{ text: "tool image", mediaUrls: ["/tmp/generated.jpg"] },
527+
{ kind: "tool" },
528+
);
529+
return { queuedFinal: false, counts: { tool: 1, block: 0, final: 0 } };
530+
},
531+
);
532+
533+
await expect(
534+
dispatchWhatsAppBufferedReply({
535+
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
536+
connectionId: "conn",
537+
context: { Body: "hi" },
538+
conversationId: "+1000",
539+
deliverReply,
540+
groupHistories: new Map(),
541+
groupHistoryKey: "+1000",
542+
maxMediaBytes: 1,
543+
msg: makeMsg(),
544+
rememberSentText,
545+
replyLogger: {
546+
info: () => {},
547+
warn: () => {},
548+
error: () => {},
549+
debug: () => {},
550+
} as never,
551+
replyPipeline: {},
552+
replyResolver: (async () => undefined) as never,
553+
route: makeRoute(),
554+
shouldClearGroupHistory: false,
555+
}),
556+
).resolves.toBe(true);
557+
558+
expect(deliverReply).toHaveBeenCalledTimes(1);
559+
expect(deliverReply).toHaveBeenCalledWith(
560+
expect.objectContaining({
561+
replyResult: expect.objectContaining({
562+
mediaUrls: ["/tmp/generated.jpg"],
563+
text: undefined,
564+
}),
565+
}),
566+
);
567+
expect(rememberSentText).toHaveBeenCalledWith(undefined, expect.any(Object));
568+
});
569+
492570
it("passes sendComposing through as the reply typing callback", async () => {
493571
const sendComposing = vi.fn(async () => undefined);
494572

extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,20 @@ function resolveWhatsAppDisableBlockStreaming(cfg: ReturnType<LoadConfigFn>): bo
5757
return !cfg.channels.whatsapp.blockStreaming;
5858
}
5959

60-
function shouldSuppressWhatsAppPayload(
60+
function resolveWhatsAppDeliverablePayload(
6161
payload: ReplyPayload,
6262
info: { kind: ReplyLifecycleKind },
63-
): boolean {
64-
if (info.kind === "tool") {
65-
return !resolveSendableOutboundReplyParts(payload).hasMedia;
66-
}
63+
): ReplyPayload | null {
6764
if (payload.isReasoning === true || payload.isCompactionNotice === true) {
68-
return true;
65+
return null;
66+
}
67+
if (info.kind === "tool") {
68+
if (!resolveSendableOutboundReplyParts(payload).hasMedia) {
69+
return null;
70+
}
71+
return { ...payload, text: undefined };
6972
}
70-
return false;
73+
return payload;
7174
}
7275

7376
export function resolveWhatsAppResponsePrefix(params: {
@@ -291,11 +294,12 @@ export async function dispatchWhatsAppBufferedReply(params: {
291294
}
292295
},
293296
deliver: async (payload: ReplyPayload, info: { kind: ReplyLifecycleKind }) => {
294-
if (shouldSuppressWhatsAppPayload(payload, info)) {
297+
const deliveryPayload = resolveWhatsAppDeliverablePayload(payload, info);
298+
if (!deliveryPayload) {
295299
return;
296300
}
297301
await params.deliverReply({
298-
replyResult: payload,
302+
replyResult: deliveryPayload,
299303
msg: params.msg,
300304
mediaLocalRoots,
301305
maxMediaBytes: params.maxMediaBytes,
@@ -307,17 +311,17 @@ export async function dispatchWhatsAppBufferedReply(params: {
307311
tableMode,
308312
});
309313
didSendReply = true;
310-
const shouldLog = payload.text ? true : undefined;
311-
params.rememberSentText(payload.text, {
314+
const shouldLog = deliveryPayload.text ? true : undefined;
315+
params.rememberSentText(deliveryPayload.text, {
312316
combinedBody: params.context.Body as string | undefined,
313317
combinedBodySessionKey: params.route.sessionKey,
314318
logVerboseMessage: shouldLog,
315319
});
316320
const fromDisplay =
317321
params.msg.chatType === "group" ? params.conversationId : (params.msg.from ?? "unknown");
318-
const reply = resolveSendableOutboundReplyParts(payload);
322+
const reply = resolveSendableOutboundReplyParts(deliveryPayload);
319323
if (shouldLogVerbose()) {
320-
const preview = payload.text != null ? reply.text : "<media>";
324+
const preview = deliveryPayload.text != null ? reply.text : "<media>";
321325
logVerbose(`Reply body: ${preview}${reply.hasMedia ? " (media)" : ""} -> ${fromDisplay}`);
322326
}
323327
},
@@ -329,7 +333,8 @@ export async function dispatchWhatsAppBufferedReply(params: {
329333
},
330334
});
331335

332-
const didQueueVisibleReply = queuedFinal || counts.block > 0 || counts.final > 0;
336+
const didQueueVisibleReply =
337+
queuedFinal || counts.tool > 0 || counts.block > 0 || counts.final > 0;
333338
if (!didQueueVisibleReply) {
334339
if (params.shouldClearGroupHistory) {
335340
params.groupHistories.set(params.groupHistoryKey, []);

0 commit comments

Comments
 (0)