From 104322c5ccc2b469eec6ee082197537df332bfab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 04:19:43 -0400 Subject: [PATCH] fix(telegram): prevent false empty replies after message-tool delivery (#115588) * fix(telegram): suppress message-tool-only reply fallbacks Honor message-tool-only delivery when a non-silent Telegram reply is skipped in both ordinary group dispatch and native slash-command turns. Keep the existing visible fallback for genuinely empty native replies and cover both dispatch surfaces with regression tests. Fixes #90091 * fix(telegram): preserve genuine delivery failure fallbacks Suppress only non-silent skips owned by message-tool-only delivery. Preserve visible fallbacks when the same turn also encounters a real dispatch or final-delivery failure, with an explicit regression for the mixed failure path. * fix(telegram): preserve native message-tool delivery errors Track genuine native slash delivery failures so message-tool-only skip suppression never hides a failed response. Cover the exact mixed empty-skip and real-error path with a regression while preserving true-empty and intentionally cancelled fallbacks. * test(telegram): preserve synchronous native delivery callbacks --- ...sage-dispatch.fallback-topic-media.test.ts | 62 +++++++++++++++++++ .../telegram/src/bot-message-dispatch.ts | 4 +- .../bot-native-commands.session-meta.test.ts | 52 ++++++++++++++++ .../telegram/src/bot-native-commands.ts | 9 ++- 4 files changed, 123 insertions(+), 4 deletions(-) diff --git a/extensions/telegram/src/bot-message-dispatch.fallback-topic-media.test.ts b/extensions/telegram/src/bot-message-dispatch.fallback-topic-media.test.ts index 221c29ea48bc..120cbd4488a6 100644 --- a/extensions/telegram/src/bot-message-dispatch.fallback-topic-media.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.fallback-topic-media.test.ts @@ -124,6 +124,68 @@ describeTelegramDispatch("dispatchTelegramMessage fallback-topic-media", () => { expect(deliverReplies).not.toHaveBeenCalled(); }); + it("does not emit an empty-response fallback for message-tool-only delivery skips", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + dispatcherOptions.onSkip?.({}, { kind: "final", reason: "empty" }); + return { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + sourceReplyDeliveryMode: "message_tool_only", + }; + }); + + await dispatchWithContext({ + context: createContext({ + chatId: -1001234, + isGroup: true, + ctxPayload: { + SessionKey: "agent:test:telegram:group:-1001234", + ChatType: "group", + } as TelegramMessageContext["ctxPayload"], + primaryCtx: { + message: { chat: { id: -1001234, type: "supergroup" } }, + } as TelegramMessageContext["primaryCtx"], + msg: { + chat: { id: -1001234, type: "supergroup" }, + message_id: 456, + } as TelegramMessageContext["msg"], + threadSpec: { id: undefined, scope: "none" }, + replyThreadId: undefined, + }), + streamMode: "off", + }); + + expect(deliverReplies).not.toHaveBeenCalled(); + }); + + it("retains the failure fallback when message-tool-only delivery also fails", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + dispatcherOptions.onSkip?.({}, { kind: "final", reason: "empty" }); + await dispatcherOptions.onError?.(new Error("Telegram final delivery failed"), { + kind: "final", + }); + return { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + sourceReplyDeliveryMode: "message_tool_only", + }; + }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: createDirectSessionPayload(), + }), + streamMode: "off", + }); + + expect(deliverReplies).toHaveBeenCalledOnce(); + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [{ text: "No response generated. Please try again." }], + }), + ); + }); + it("does not emit a silent-reply fallback for no-response group turns", async () => { dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ queuedFinal: false, diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 207212c4ec76..bd58d34b3dfc 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -489,8 +489,8 @@ export const dispatchTelegramMessage = async ({ !suppressFailureFallback && !progress.finalAnswerDelivered() && (state.dispatchError || - deliverySummary.skippedNonSilent > 0 || - deliverySummary.failedNonSilent > 0); + deliverySummary.failedNonSilent > 0 || + (deliverySummary.skippedNonSilent > 0 && !state.suppressSilentReplyFallback)); if (shouldSendFailureFallback) { const fallbackText = state.dispatchError ? "Something went wrong while processing your request. Please try again." diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index 47e32c4bc221..d240eefa9651 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -1354,6 +1354,58 @@ describe("registerTelegramNativeCommands — session metadata", () => { expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); }); + it("does not emit the empty fallback for a message-tool-only native reply", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" }); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + sourceReplyDeliveryMode: "message_tool_only", + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); + }); + + it("retains the native fallback when message-tool-only delivery also fails", async () => { + dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { + plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" }); + plan.delivery.onError?.(new Error("Telegram final delivery failed"), { + kind: "final", + }); + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: plan.ctxPayload, + routeSessionKey: plan.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + sourceReplyDeliveryMode: "message_tool_only", + }, + }; + }); + const { handler } = registerAndResolveStatusHandler({ cfg: {} }); + + await handler(createTelegramPrivateCommandContext()); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce(); + expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [{ text: "No response generated. Please try again." }], + }), + ); + }); + it("retains the empty fallback for a true non-silent metadata-only native reply", async () => { dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => { plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" }); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index f18a0e8c2a4e..2f37207e69e7 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -1650,6 +1650,7 @@ export const registerTelegramNativeCommands = ({ delivered: false, intentionallySuppressed: false, skippedNonSilent: 0, + failedNonSilent: 0, }; const { deliverReplies } = await loadTelegramNativeCommandDeliveryRuntime(); @@ -1735,6 +1736,7 @@ export const registerTelegramNativeCommands = ({ } }, onError: (err, info) => { + deliveryState.failedNonSilent += 1; runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`)); }, }, @@ -1743,14 +1745,17 @@ export const registerTelegramNativeCommands = ({ disableBlockStreaming, }, }; - await ( + const turnResult = await ( telegramDeps.dispatchChannelInboundTurn ?? defaultTelegramNativeCommandDeps.dispatchChannelInboundTurn )(turnPlan); if ( !deliveryState.delivered && !deliveryState.intentionallySuppressed && - deliveryState.skippedNonSilent > 0 + deliveryState.skippedNonSilent > 0 && + (!turnResult.dispatched || + turnResult.dispatchResult.sourceReplyDeliveryMode !== "message_tool_only" || + deliveryState.failedNonSilent > 0) ) { await deliverReplies({ replies: [{ text: EMPTY_RESPONSE_FALLBACK }],