From 88dc177afc29690cfdf79d05b4e73f94301649b2 Mon Sep 17 00:00:00 2001 From: Marcus Castro <7562095+mcaxtr@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:26:45 -0300 Subject: [PATCH] fix(auto-reply): count message tool sends as delivery (#90123) --- .../monitor/inbound-dispatch.test.ts | 25 ++++++++++++ .../auto-reply/monitor/inbound-dispatch.ts | 11 +++--- src/agents/embedded-agent-runner/run.ts | 12 ++++++ .../embedded-agent-runner/run/attempt.ts | 5 +++ .../run/message-tool-terminal.test.ts | 6 +++ .../run/message-tool-terminal.ts | 2 + src/agents/embedded-agent-runner/run/types.ts | 1 + src/agents/embedded-agent-runner/types.ts | 2 + src/auto-reply/get-reply-options.types.ts | 2 + src/auto-reply/reply/agent-runner.ts | 10 +++++ .../reply/dispatch-from-config.test.ts | 38 +++++++++++++++++++ src/auto-reply/reply/dispatch-from-config.ts | 12 +++++- .../reply/dispatch-from-config.types.ts | 1 + src/channels/turn/dispatch-result.ts | 2 + src/plugin-sdk/inbound-reply-dispatch.test.ts | 7 ++++ 15 files changed, 130 insertions(+), 6 deletions(-) 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 87796cdfc2fd..743979a3dc3a 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -1139,6 +1139,31 @@ describe("whatsapp inbound dispatch", () => { expect(rememberSentText).toHaveBeenCalledTimes(1); }); + it("returns success when shared dispatch observes message-tool delivery", async () => { + const deliverReply = vi.fn(async () => acceptedDeliveryResult()); + const rememberSentText = vi.fn(); + dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce( + async (params: CapturedDispatchParams) => { + capturedDispatchParams = params; + return { + queuedFinal: false, + counts: { tool: 0, block: 0, final: 0 }, + observedReplyDelivery: true, + }; + }, + ); + + await expect( + dispatchBufferedReply({ + deliverReply, + rememberSentText, + }), + ).resolves.toBe(true); + + expect(deliverReply).not.toHaveBeenCalled(); + expect(rememberSentText).not.toHaveBeenCalled(); + }); + it("does not treat generated WhatsApp text as sent when the provider did not accept it", async () => { const deliverReply = vi.fn(async () => unacceptedDeliveryResult()); const rememberSentText = vi.fn(); diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index 4faa03bf7cb7..e2fd5e07e39a 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -654,7 +654,7 @@ export async function dispatchWhatsAppBufferedReply(params: { void statusReactionController.setThinking(); } - const { queuedFinal, counts } = await dispatchReplyWithBufferedBlockDispatcher({ + const dispatchResult = await dispatchReplyWithBufferedBlockDispatcher({ ctx: params.context, cfg: params.cfg, replyResolver: params.replyResolver, @@ -798,7 +798,8 @@ export async function dispatchWhatsAppBufferedReply(params: { : {}), }, }); - const didQueueVisibleReply = hasVisibleInboundReplyDispatch({ queuedFinal, counts }); + const didQueueVisibleReply = hasVisibleInboundReplyDispatch(dispatchResult); + const didDeliverVisibleReply = didSendReply || dispatchResult.observedReplyDelivery === true; if (!didQueueVisibleReply) { if (statusReactionController) { void finalizeWhatsAppStatusReaction({ @@ -819,8 +820,8 @@ export async function dispatchWhatsAppBufferedReply(params: { if (statusReactionController) { void finalizeWhatsAppStatusReaction({ controller: statusReactionController, - outcome: didSendReply ? "done" : "error", - hasFinalResponse: didSendReply, + outcome: didDeliverVisibleReply ? "done" : "error", + hasFinalResponse: didDeliverVisibleReply, removeAckAfterReply, timing: statusReactionTiming, }); @@ -830,7 +831,7 @@ export async function dispatchWhatsAppBufferedReply(params: { params.groupHistories.set(params.groupHistoryKey, []); } - return didSendReply; + return didDeliverVisibleReply; } async function finalizeWhatsAppStatusReaction(params: { diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index ebdf0f90f994..ed073231fc3f 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -322,6 +322,7 @@ function normalizeEmbeddedRunAttemptResult( messagingToolSourceReplyPayloads?: | EmbeddedRunAttemptForRunner["messagingToolSourceReplyPayloads"] | null; + didDeliverSourceReplyViaMessageTool?: boolean | null; itemLifecycle?: EmbeddedRunAttemptForRunner["itemLifecycle"] | null; }; return { @@ -334,6 +335,7 @@ function normalizeEmbeddedRunAttemptResult( messagingToolSentMediaUrls: raw.messagingToolSentMediaUrls ?? [], messagingToolSentTargets: raw.messagingToolSentTargets ?? [], messagingToolSourceReplyPayloads: raw.messagingToolSourceReplyPayloads ?? [], + didDeliverSourceReplyViaMessageTool: raw.didDeliverSourceReplyViaMessageTool === true, itemLifecycle: raw.itemLifecycle ?? { startedCount: 0, completedCount: 0, @@ -3061,6 +3063,8 @@ export async function runEmbeddedAgent( agentHarnessResultClassification: attempt.agentHarnessResultClassification, }, didSendViaMessagingTool: attempt.didSendViaMessagingTool, + didDeliverSourceReplyViaMessageTool: + attempt.didDeliverSourceReplyViaMessageTool === true, didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt, messagingToolSentTexts: attempt.messagingToolSentTexts, messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls, @@ -3306,6 +3310,8 @@ export async function runEmbeddedAgent( agentHarnessResultClassification: attempt.agentHarnessResultClassification, }, didSendViaMessagingTool: attempt.didSendViaMessagingTool, + didDeliverSourceReplyViaMessageTool: + attempt.didDeliverSourceReplyViaMessageTool === true, didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt, messagingToolSentTexts: attempt.messagingToolSentTexts, messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls, @@ -3360,6 +3366,8 @@ export async function runEmbeddedAgent( agentHarnessResultClassification: attempt.agentHarnessResultClassification, }, didSendViaMessagingTool: attempt.didSendViaMessagingTool, + didDeliverSourceReplyViaMessageTool: + attempt.didDeliverSourceReplyViaMessageTool === true, didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt, messagingToolSentTexts: attempt.messagingToolSentTexts, messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls, @@ -3481,6 +3489,8 @@ export async function runEmbeddedAgent( agentHarnessResultClassification: attempt.agentHarnessResultClassification, }, didSendViaMessagingTool: attempt.didSendViaMessagingTool, + didDeliverSourceReplyViaMessageTool: + attempt.didDeliverSourceReplyViaMessageTool === true, didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt, messagingToolSentTexts: attempt.messagingToolSentTexts, messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls, @@ -3624,6 +3634,8 @@ export async function runEmbeddedAgent( autoCompactionCount > 0 ? { lastTurnCompactions: autoCompactionCount } : undefined, }, didSendViaMessagingTool: attempt.didSendViaMessagingTool, + didDeliverSourceReplyViaMessageTool: + attempt.didDeliverSourceReplyViaMessageTool === true, didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt, messagingToolSentTexts: attempt.messagingToolSentTexts, messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls, diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 437462b31cd5..ade711a93a35 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -2268,9 +2268,13 @@ export async function runEmbeddedAttempt( applySystemPromptToSession(activeSession, nextSystemPrompt); }; setActiveSessionSystemPrompt(systemPromptText); + let didDeliverSourceReplyViaMessageTool = false; installMessageToolOnlyTerminalHook({ agent: activeSession.agent, sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, + onDeliveredSourceReply: () => { + didDeliverSourceReplyViaMessageTool = true; + }, }); prepStages.mark("agent-session"); if (isRawModelRun) { @@ -5130,6 +5134,7 @@ export async function runEmbeddedAttempt( currentAttemptAssistant, lastToolError, didSendViaMessagingTool: didSendViaMessagingTool(), + didDeliverSourceReplyViaMessageTool, didSendDeterministicApprovalPrompt: didSendDeterministicApprovalPromptNow, messagingToolSentTexts: getMessagingToolSentTexts(), messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(), diff --git a/src/agents/embedded-agent-runner/run/message-tool-terminal.test.ts b/src/agents/embedded-agent-runner/run/message-tool-terminal.test.ts index 91759a02dded..9b4905960529 100644 --- a/src/agents/embedded-agent-runner/run/message-tool-terminal.test.ts +++ b/src/agents/embedded-agent-runner/run/message-tool-terminal.test.ts @@ -160,9 +160,11 @@ describe("message-tool-only terminal sends", () => { details: { rewritten: true }, })); const agent = { afterToolCall: previousAfterToolCall } as unknown as Agent; + const onDeliveredSourceReply = vi.fn(); installMessageToolOnlyTerminalHook({ agent, sourceReplyDeliveryMode: "message_tool_only", + onDeliveredSourceReply, }); await expect( @@ -178,6 +180,7 @@ describe("message-tool-only terminal sends", () => { terminate: true, }); expect(previousAfterToolCall).toHaveBeenCalledTimes(1); + expect(onDeliveredSourceReply).toHaveBeenCalledTimes(1); }); it("leaves existing after-tool-call output alone when the send failed", async () => { @@ -187,9 +190,11 @@ describe("message-tool-only terminal sends", () => { isError: true, })); const agent = { afterToolCall: previousAfterToolCall } as unknown as Agent; + const onDeliveredSourceReply = vi.fn(); installMessageToolOnlyTerminalHook({ agent, sourceReplyDeliveryMode: "message_tool_only", + onDeliveredSourceReply, }); await expect( @@ -205,6 +210,7 @@ describe("message-tool-only terminal sends", () => { isError: true, }); expect(previousAfterToolCall).toHaveBeenCalledTimes(1); + expect(onDeliveredSourceReply).not.toHaveBeenCalled(); }); it("does not install a wrapper for non-message-tool-only delivery", async () => { diff --git a/src/agents/embedded-agent-runner/run/message-tool-terminal.ts b/src/agents/embedded-agent-runner/run/message-tool-terminal.ts index 4a6e024ae969..11f4a1663be6 100644 --- a/src/agents/embedded-agent-runner/run/message-tool-terminal.ts +++ b/src/agents/embedded-agent-runner/run/message-tool-terminal.ts @@ -192,6 +192,7 @@ export function shouldTerminateAfterMessageToolOnlySend(params: { export function installMessageToolOnlyTerminalHook(params: { agent: Agent; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + onDeliveredSourceReply?: () => void; }): void { if (params.sourceReplyDeliveryMode !== "message_tool_only") { return; @@ -206,6 +207,7 @@ export function installMessageToolOnlyTerminalHook(params: { hookResult, }) ) { + params.onDeliveredSourceReply?.(); return { ...hookResult, terminate: true }; } return hookResult; diff --git a/src/agents/embedded-agent-runner/run/types.ts b/src/agents/embedded-agent-runner/run/types.ts index 46d1481e3446..15d3930e0c3a 100644 --- a/src/agents/embedded-agent-runner/run/types.ts +++ b/src/agents/embedded-agent-runner/run/types.ts @@ -157,6 +157,7 @@ export type EmbeddedRunAttemptResult = { currentAttemptAssistant?: AssistantMessage | undefined; lastToolError?: ToolErrorSummary; didSendViaMessagingTool: boolean; + didDeliverSourceReplyViaMessageTool?: boolean; didSendDeterministicApprovalPrompt?: boolean; messagingToolSentTexts: string[]; messagingToolSentMediaUrls: string[]; diff --git a/src/agents/embedded-agent-runner/types.ts b/src/agents/embedded-agent-runner/types.ts index 4e4376373073..d3c3a6908e29 100644 --- a/src/agents/embedded-agent-runner/types.ts +++ b/src/agents/embedded-agent-runner/types.ts @@ -193,6 +193,8 @@ export type EmbeddedAgentRunResult = { // True if a messaging tool successfully sent a message. // Used to suppress agent's confirmation text. didSendViaMessagingTool?: boolean; + // True if message_tool_only delivered a visible reply to the current source conversation. + didDeliverSourceReplyViaMessageTool?: boolean; // True if a deterministic approval prompt was sent through the tool-result channel. didSendDeterministicApprovalPrompt?: boolean; // Texts successfully sent via messaging tools during the run. diff --git a/src/auto-reply/get-reply-options.types.ts b/src/auto-reply/get-reply-options.types.ts index 8f907a6b6662..57458d30762d 100644 --- a/src/auto-reply/get-reply-options.types.ts +++ b/src/auto-reply/get-reply-options.types.ts @@ -197,6 +197,8 @@ export type GetReplyOptions = { queuedFollowupLifecycle?: QueuedReplyLifecycle; /** Allow channel-owned progress UI while final/source reply delivery remains message-tool-only. */ allowProgressCallbacksWhenSourceDeliverySuppressed?: boolean; + /** Called when a suppressed source reply mode observes visible delivery through another path. */ + onObservedReplyDelivery?: () => Promise | void; disableBlockStreaming?: boolean; /** Timeout for block reply delivery (ms). */ blockReplyTimeoutMs?: number; diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 40caea7cedc0..cd7aa346275c 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -9,6 +9,7 @@ import { } from "../../agents/agent-scope.js"; import { resolveContextTokensForModel } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; +import { hasVisibleAgentPayload } from "../../agents/embedded-agent-runner/delivery-evidence.js"; import { formatEmbeddedAgentQueueFailureSummary, queueEmbeddedAgentMessageWithOutcomeAsync, @@ -1818,6 +1819,15 @@ export async function runReplyAgent(params: { messagingToolSentMediaUrls: runResult.messagingToolSentMediaUrls, messagingToolSentTargets: runResult.messagingToolSentTargets, }); + const committedMessagingToolSourceReplyDelivery = + runResult.didDeliverSourceReplyViaMessageTool === true || + hasVisibleAgentPayload({ payloads: runResult.messagingToolSourceReplyPayloads }); + if ( + opts?.sourceReplyDeliveryMode === "message_tool_only" && + committedMessagingToolSourceReplyDelivery + ) { + await opts.onObservedReplyDelivery?.(); + } const returnSilentFallbackFailureIfNeeded = async (): Promise => { const silentFallbackFailurePayload = buildSilentFallbackFailurePayload({ fallbackTransition, diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 3c1506960963..9af519fd9ffc 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -8029,6 +8029,44 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => } }); + it("treats message-tool-only observed delivery as visible for fallback eligibility", async () => { + setNoAbort(); + sessionStoreMocks.currentEntry = { + sessionId: "s1", + updatedAt: 0, + sendPolicy: "allow", + }; + const dispatcher = createDispatcher(); + const observedReplyDelivery = vi.fn(); + const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => { + expect(opts?.sourceReplyDeliveryMode).toBe("message_tool_only"); + await opts?.onObservedReplyDelivery?.(); + return { text: "private final reply" } satisfies ReplyPayload; + }); + + const result = await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + ChatType: "channel", + SessionKey: "test:session", + }), + cfg: emptyConfig, + dispatcher, + replyResolver, + replyOptions: { + sourceReplyDeliveryMode: "message_tool_only", + onObservedReplyDelivery: observedReplyDelivery, + }, + }); + + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(observedReplyDelivery).toHaveBeenCalledTimes(1); + expect(result.queuedFinal).toBe(false); + expect(result.observedReplyDelivery).toBe(true); + expect(result.noVisibleReplyFallbackEligible).toBeUndefined(); + expect(result.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); + }); + it("preserves hook-blocked metadata when source delivery is message-tool-only", async () => { setNoAbort(); sessionStoreMocks.currentEntry = { diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 69c7cc243682..a239dc84a3e6 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -1299,6 +1299,14 @@ export async function dispatchReplyFromConfig( }; const getQueuedFollowupAbortSignal = () => dispatchReplyOperation?.abortSignal ?? params.replyOptions?.abortSignal; + let observedReplyDelivery = false; + const markObservedReplyDelivery = async () => { + if (observedReplyDelivery) { + return; + } + observedReplyDelivery = true; + await params.replyOptions?.onObservedReplyDelivery?.(); + }; const getReplyOptions = () => { const abortSignal = getDispatchAbortSignal(); if (!abortSignal) { @@ -2451,6 +2459,7 @@ export async function dispatchReplyFromConfig( { ...getReplyOptions(), sourceReplyDeliveryMode, + onObservedReplyDelivery: markObservedReplyDelivery, suppressToolErrorWarnings, shouldSuppressToolErrorWarnings, typingPolicy: typing.typingPolicy, @@ -2953,7 +2962,8 @@ export async function dispatchReplyFromConfig( return attachSourceReplyDeliveryMode({ queuedFinal, counts, - ...(!queuedFinal && !emptyFinalAllowedAsSilent + ...(observedReplyDelivery ? { observedReplyDelivery } : {}), + ...(!queuedFinal && !observedReplyDelivery && !emptyFinalAllowedAsSilent ? { noVisibleReplyFallbackEligible: true } : {}), ...(beforeAgentRunBlocked ? { beforeAgentRunBlocked } : {}), diff --git a/src/auto-reply/reply/dispatch-from-config.types.ts b/src/auto-reply/reply/dispatch-from-config.types.ts index 9c9fc1d7ecec..8974e7b0ac76 100644 --- a/src/auto-reply/reply/dispatch-from-config.types.ts +++ b/src/auto-reply/reply/dispatch-from-config.types.ts @@ -11,6 +11,7 @@ export type DispatchFromConfigResult = { failedCounts?: Partial>; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; sendPolicyDenied?: boolean; + observedReplyDelivery?: boolean; noVisibleReplyFallbackEligible?: boolean; beforeAgentRunBlocked?: boolean; }; diff --git a/src/channels/turn/dispatch-result.ts b/src/channels/turn/dispatch-result.ts index d69c86109cd1..1044a86374a3 100644 --- a/src/channels/turn/dispatch-result.ts +++ b/src/channels/turn/dispatch-result.ts @@ -5,6 +5,7 @@ export type ChannelTurnDispatchResultLike = | { queuedFinal?: boolean; counts?: Partial>; + observedReplyDelivery?: boolean; } | null | undefined; @@ -41,6 +42,7 @@ export function hasVisibleChannelTurnDispatch( const counts = resolveChannelTurnDispatchCounts(result); // Non-count signals cover delivery paths that bypass the buffered reply dispatcher. return ( + result?.observedReplyDelivery === true || signals.observedReplyDelivery === true || signals.fallbackDelivered === true || signals.deliverySummaryDelivered === true || diff --git a/src/plugin-sdk/inbound-reply-dispatch.test.ts b/src/plugin-sdk/inbound-reply-dispatch.test.ts index 00880a4bea2d..20f3d8308c2c 100644 --- a/src/plugin-sdk/inbound-reply-dispatch.test.ts +++ b/src/plugin-sdk/inbound-reply-dispatch.test.ts @@ -283,6 +283,13 @@ describe("recordInboundSessionAndDispatchReply", () => { counts: { tool: 0, block: 1, final: 0 }, }), ).toBe(true); + expect( + hasVisibleInboundReplyDispatch({ + queuedFinal: false, + counts: { tool: 0, block: 0, final: 0 }, + observedReplyDelivery: true, + }), + ).toBe(true); expect( hasFinalInboundReplyDispatch({ queuedFinal: false,