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)