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
This commit is contained in:
Peter Steinberger
2026-07-29 04:19:43 -04:00
committed by GitHub
parent 98dae674cb
commit 104322c5cc
4 changed files with 123 additions and 4 deletions
@@ -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,
@@ -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."
@@ -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" });
@@ -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 }],