mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 02:45:38 -06:00
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:
@@ -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" });
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user