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.
This commit is contained in:
Peter Lee
2026-08-05 05:48:50 -05:00
committed by GitHub
parent a52b336f26
commit f68ecec40c
2 changed files with 181 additions and 8 deletions
@@ -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" });
+25 -8
View File
@@ -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<unknown> | 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)