fix(whatsapp): deliver tool replies that include media (#60968)

Merged via squash.

Prepared head SHA: 26704020a4
Co-authored-by: adaclaw <266167987+adaclaw@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
This commit is contained in:
Ada
2026-04-25 03:44:59 +01:00
committed by GitHub
parent 8f11e5ad18
commit 413e407fb8
3 changed files with 122 additions and 22 deletions

View File

@@ -193,6 +193,7 @@ Docs: https://docs.openclaw.ai
- 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.
- WhatsApp: transcribe accepted voice notes before agent dispatch while keeping spoken transcripts out of command authorization. (#64120) Thanks @rogerdigital.
- 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.
- WhatsApp: deliver media generated by tool-result replies while still suppressing text-only tool chatter. (#60968) Thanks @adaclaw.
## 2026.4.23

View File

@@ -2,6 +2,14 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
let capturedDispatchParams: unknown;
type CapturedReplyPayload = {
text?: string;
isReasoning?: boolean;
isCompactionNotice?: boolean;
mediaUrl?: string;
mediaUrls?: string[];
};
const { dispatchReplyWithBufferedBlockDispatcherMock } = vi.hoisted(() => ({
dispatchReplyWithBufferedBlockDispatcherMock: vi.fn(async (params: { ctx: unknown }) => {
capturedDispatchParams = params;
@@ -36,10 +44,20 @@ vi.mock("./runtime-api.js", () => ({
},
resolveInboundLastRouteSessionKey: (params: { sessionKey: string }) => params.sessionKey,
resolveMarkdownTableMode: () => undefined,
resolveSendableOutboundReplyParts: (payload: { text?: string }) => ({
text: payload.text ?? "",
hasMedia: false,
}),
resolveSendableOutboundReplyParts: (payload: {
text?: string;
mediaUrls?: string[];
mediaUrl?: string;
}) => {
const urls = [
...(Array.isArray(payload.mediaUrls) ? payload.mediaUrls : []),
...(payload.mediaUrl ? [payload.mediaUrl] : []),
];
return {
text: payload.text ?? "",
hasMedia: urls.length > 0,
};
},
resolveTextChunkLimit: () => 4000,
shouldLogVerbose: () => false,
toLocationContext: () => ({}),
@@ -91,7 +109,7 @@ function getCapturedDeliver() {
capturedDispatchParams as {
dispatcherOptions?: {
deliver?: (
payload: { text?: string; isReasoning?: boolean; isCompactionNotice?: boolean },
payload: CapturedReplyPayload,
info: { kind: "tool" | "block" | "final" },
) => Promise<void>;
};
@@ -360,7 +378,7 @@ describe("whatsapp inbound dispatch", () => {
expect(groupHistories.get("whatsapp:default:group:123@g.us") ?? []).toHaveLength(0);
});
it("delivers block and final WhatsApp payloads, but suppresses tool payloads", async () => {
it("delivers block and final WhatsApp payloads; suppresses text-only tool payloads but delivers tool media", async () => {
const deliverReply = vi.fn(async () => undefined);
const rememberSentText = vi.fn();
@@ -376,10 +394,27 @@ describe("whatsapp inbound dispatch", () => {
expect(deliverReply).not.toHaveBeenCalled();
expect(rememberSentText).not.toHaveBeenCalled();
await deliver?.(
{ text: "tool image", mediaUrls: ["/tmp/generated.jpg"] },
{
kind: "tool",
},
);
expect(deliverReply).toHaveBeenCalledTimes(1);
expect(rememberSentText).toHaveBeenCalledTimes(1);
expect(deliverReply).toHaveBeenLastCalledWith(
expect.objectContaining({
replyResult: expect.objectContaining({
mediaUrls: ["/tmp/generated.jpg"],
text: undefined,
}),
}),
);
await deliver?.({ text: "block payload" }, { kind: "block" });
await deliver?.({ text: "final payload" }, { kind: "final" });
expect(deliverReply).toHaveBeenCalledTimes(2);
expect(rememberSentText).toHaveBeenCalledTimes(2);
expect(deliverReply).toHaveBeenCalledTimes(3);
expect(rememberSentText).toHaveBeenCalledTimes(3);
});
it("suppresses reasoning and compaction payloads before WhatsApp delivery", async () => {
@@ -473,6 +508,65 @@ describe("whatsapp inbound dispatch", () => {
expect(rememberSentText).toHaveBeenCalledTimes(1);
});
it("returns true for tool-only media turns after delivering media", async () => {
const deliverReply = vi.fn(async () => undefined);
const rememberSentText = vi.fn();
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce(
async (params: {
ctx: unknown;
dispatcherOptions?: {
deliver?: (
payload: CapturedReplyPayload,
info: { kind: "tool" | "block" | "final" },
) => Promise<void>;
};
}) => {
capturedDispatchParams = params;
await params.dispatcherOptions?.deliver?.(
{ text: "tool image", mediaUrls: ["/tmp/generated.jpg"] },
{ kind: "tool" },
);
return { queuedFinal: false, counts: { tool: 1, block: 0, final: 0 } };
},
);
await expect(
dispatchWhatsAppBufferedReply({
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
connectionId: "conn",
context: { Body: "hi" },
conversationId: "+1000",
deliverReply,
groupHistories: new Map(),
groupHistoryKey: "+1000",
maxMediaBytes: 1,
msg: makeMsg(),
rememberSentText,
replyLogger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
} as never,
replyPipeline: {},
replyResolver: (async () => undefined) as never,
route: makeRoute(),
shouldClearGroupHistory: false,
}),
).resolves.toBe(true);
expect(deliverReply).toHaveBeenCalledTimes(1);
expect(deliverReply).toHaveBeenCalledWith(
expect.objectContaining({
replyResult: expect.objectContaining({
mediaUrls: ["/tmp/generated.jpg"],
text: undefined,
}),
}),
);
expect(rememberSentText).toHaveBeenCalledWith(undefined, expect.any(Object));
});
it("passes sendComposing through as the reply typing callback", async () => {
const sendComposing = vi.fn(async () => undefined);

View File

@@ -57,17 +57,20 @@ function resolveWhatsAppDisableBlockStreaming(cfg: ReturnType<LoadConfigFn>): bo
return !cfg.channels.whatsapp.blockStreaming;
}
function shouldSuppressWhatsAppPayload(
function resolveWhatsAppDeliverablePayload(
payload: ReplyPayload,
info: { kind: ReplyLifecycleKind },
): boolean {
if (info.kind === "tool") {
return true;
}
): ReplyPayload | null {
if (payload.isReasoning === true || payload.isCompactionNotice === true) {
return true;
return null;
}
return false;
if (info.kind === "tool") {
if (!resolveSendableOutboundReplyParts(payload).hasMedia) {
return null;
}
return { ...payload, text: undefined };
}
return payload;
}
export function resolveWhatsAppResponsePrefix(params: {
@@ -291,11 +294,12 @@ export async function dispatchWhatsAppBufferedReply(params: {
}
},
deliver: async (payload: ReplyPayload, info: { kind: ReplyLifecycleKind }) => {
if (shouldSuppressWhatsAppPayload(payload, info)) {
const deliveryPayload = resolveWhatsAppDeliverablePayload(payload, info);
if (!deliveryPayload) {
return;
}
await params.deliverReply({
replyResult: payload,
replyResult: deliveryPayload,
msg: params.msg,
mediaLocalRoots,
maxMediaBytes: params.maxMediaBytes,
@@ -307,17 +311,17 @@ export async function dispatchWhatsAppBufferedReply(params: {
tableMode,
});
didSendReply = true;
const shouldLog = payload.text ? true : undefined;
params.rememberSentText(payload.text, {
const shouldLog = deliveryPayload.text ? true : undefined;
params.rememberSentText(deliveryPayload.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(payload);
const reply = resolveSendableOutboundReplyParts(deliveryPayload);
if (shouldLogVerbose()) {
const preview = payload.text != null ? reply.text : "<media>";
const preview = deliveryPayload.text != null ? reply.text : "<media>";
logVerbose(`Reply body: ${preview}${reply.hasMedia ? " (media)" : ""} -> ${fromDisplay}`);
}
},
@@ -329,7 +333,8 @@ export async function dispatchWhatsAppBufferedReply(params: {
},
});
const didQueueVisibleReply = queuedFinal || counts.block > 0 || counts.final > 0;
const didQueueVisibleReply =
queuedFinal || counts.tool > 0 || counts.block > 0 || counts.final > 0;
if (!didQueueVisibleReply) {
if (params.shouldClearGroupHistory) {
params.groupHistories.set(params.groupHistoryKey, []);