From f68ecec40c5431a951b8a582791f3d3cbf6ff4e2 Mon Sep 17 00:00:00 2001
From: Peter Lee
Date: Wed, 5 Aug 2026 05:48:50 -0500
Subject: [PATCH] fix(telegram): preserve slash-command failure outcomes
Track final delivery outcomes at the Telegram native-command owner so failed finals produce a visible fallback without overriding intentional suppression or partial delivery.
---
.../bot-native-commands.session-meta.test.ts | 156 ++++++++++++++++++
.../telegram/src/bot-native-commands.ts | 33 +++-
2 files changed, 181 insertions(+), 8 deletions(-)
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 ec206393c613..29a7cba6c95b 100644
--- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts
+++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts
@@ -1,4 +1,5 @@
// Telegram tests cover bot native commands.session meta plugin behavior.
+import { createChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-dispatch-runtime";
@@ -1445,6 +1446,161 @@ describe("registerTelegramNativeCommands — session metadata", () => {
);
});
+ it("emits the fallback when a non-final suppression precedes a final failure", async () => {
+ dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => {
+ await plan.delivery.onDelivered?.(
+ { text: "cancelled tool reply" },
+ { kind: "tool" },
+ {
+ visibleReplySent: false,
+ suppression: { reason: "cancelled_by_reply_payload_sending_hook" },
+ },
+ );
+ 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 },
+ },
+ };
+ });
+ 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("emits the fallback when a suppressed block reply precedes a final failure", async () => {
+ dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => {
+ await plan.delivery.onDelivered?.(
+ { text: "cancelled block reply" },
+ { kind: "block" },
+ {
+ visibleReplySent: false,
+ suppression: { reason: "empty_after_reply_payload_sending_hook" },
+ },
+ );
+ 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 },
+ },
+ };
+ });
+ const { handler } = registerAndResolveStatusHandler({ cfg: {} });
+
+ await handler(createTelegramPrivateCommandContext());
+
+ expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce();
+ });
+
+ it("emits the fallback when a final failure precedes a later suppressed final", async () => {
+ dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => {
+ plan.delivery.onError?.(new Error("Telegram final delivery failed"), {
+ kind: "final",
+ });
+ await plan.delivery.onDelivered?.(
+ { text: "cancelled final reply" },
+ { kind: "final" },
+ {
+ visibleReplySent: false,
+ suppression: { reason: "cancelled_by_reply_payload_sending_hook" },
+ },
+ );
+ return {
+ admission: { kind: "dispatch" },
+ dispatched: true,
+ ctxPayload: plan.ctxPayload,
+ routeSessionKey: plan.route.sessionKey,
+ dispatchResult: {
+ queuedFinal: false,
+ counts: { block: 0, final: 0, tool: 0 },
+ },
+ };
+ });
+ const { handler } = registerAndResolveStatusHandler({ cfg: {} });
+
+ await handler(createTelegramPrivateCommandContext());
+
+ expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce();
+ });
+
+ it("preserves a suppressed final after a non-final delivery failure", async () => {
+ dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => {
+ plan.delivery.onError?.(new Error("Telegram tool delivery failed"), {
+ kind: "tool",
+ });
+ await plan.delivery.onDelivered?.(
+ { text: "cancelled final reply" },
+ { kind: "final" },
+ {
+ visibleReplySent: false,
+ suppression: { reason: "cancelled_by_reply_payload_sending_hook" },
+ },
+ );
+ return {
+ admission: { kind: "dispatch" },
+ dispatched: true,
+ ctxPayload: plan.ctxPayload,
+ routeSessionKey: plan.route.sessionKey,
+ dispatchResult: {
+ queuedFinal: false,
+ counts: { block: 0, final: 0, tool: 0 },
+ },
+ };
+ });
+ const { handler } = registerAndResolveStatusHandler({ cfg: {} });
+
+ await handler(createTelegramPrivateCommandContext());
+
+ expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled();
+ });
+
+ it("does not emit the fallback after a partially delivered final", async () => {
+ dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => {
+ plan.delivery.onError?.(
+ createChannelPartialDeliveryError(new Error("Telegram final delivery failed"), {
+ visibleReplySent: true,
+ }),
+ { 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 },
+ },
+ };
+ });
+ const { handler } = registerAndResolveStatusHandler({ cfg: {} });
+
+ await handler(createTelegramPrivateCommandContext());
+
+ expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled();
+ });
+
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 15f0c96cd9d7..a6266166522b 100644
--- a/extensions/telegram/src/bot-native-commands.ts
+++ b/extensions/telegram/src/bot-native-commands.ts
@@ -8,7 +8,10 @@ import {
resolveDefaultModelForAgent,
resolveThinkingDefaultWithRuntimeCatalog,
} from "openclaw/plugin-sdk/agent-runtime";
-import type { ChannelInboundTurnPlan } from "openclaw/plugin-sdk/channel-inbound";
+import {
+ isChannelPartialDeliveryError,
+ type ChannelInboundTurnPlan,
+} from "openclaw/plugin-sdk/channel-inbound";
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native";
import {
@@ -1732,10 +1735,10 @@ export const registerTelegramNativeCommands = ({
resolveTelegramNativeCommandDisableBlockStreaming(runtimeTelegramCfg);
const deliveryState = {
delivered: false,
- intentionallySuppressed: false,
skippedNonSilent: 0,
failedNonSilent: 0,
};
+ let finalReplyOutcome: "accepted" | "failed" | "suppressed" | undefined;
const { deliverReplies } = await loadTelegramNativeCommandDeliveryRuntime();
let recordSessionMetaTask: Promise | undefined;
@@ -1810,17 +1813,31 @@ export const registerTelegramNativeCommands = ({
suppression: { reason: "no_visible_result" as const },
};
},
- onDelivered: (_payload, _info, result) => {
+ onDelivered: (_payload, info, result) => {
const reason = result?.suppression?.reason;
+ if (info.kind === "final" && result?.visibleReplySent) {
+ finalReplyOutcome = "accepted";
+ }
if (
- reason === "cancelled_by_reply_payload_sending_hook" ||
- reason === "empty_after_reply_payload_sending_hook"
+ info.kind === "final" &&
+ finalReplyOutcome !== "failed" &&
+ (reason === "cancelled_by_reply_payload_sending_hook" ||
+ reason === "empty_after_reply_payload_sending_hook")
) {
- deliveryState.intentionallySuppressed = true;
+ finalReplyOutcome = "suppressed";
}
},
onError: (err, info) => {
deliveryState.failedNonSilent += 1;
+ const partialDelivery = isChannelPartialDeliveryError(err);
+ if (partialDelivery) {
+ deliveryState.delivered = true;
+ logVerbose("telegram slash reply partially delivered before failure");
+ }
+ if (info.kind === "final") {
+ // A failed final outweighs any earlier suppression until a final delivers.
+ finalReplyOutcome = partialDelivery ? "accepted" : "failed";
+ }
runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`));
},
},
@@ -1835,8 +1852,8 @@ export const registerTelegramNativeCommands = ({
)(turnPlan);
if (
!deliveryState.delivered &&
- !deliveryState.intentionallySuppressed &&
- deliveryState.skippedNonSilent > 0 &&
+ finalReplyOutcome !== "suppressed" &&
+ (deliveryState.skippedNonSilent > 0 || deliveryState.failedNonSilent > 0) &&
(!turnResult.dispatched ||
turnResult.dispatchResult.sourceReplyDeliveryMode !== "message_tool_only" ||
deliveryState.failedNonSilent > 0)