From 863198f0c9c33ab4a797ef39cdc4bd387f63309d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 3 May 2026 10:55:58 -0700 Subject: [PATCH] fix(commands): tolerate empty plugin command replies Fixes #74800. --- CHANGELOG.md | 1 + .../bot-native-commands.session-meta.test.ts | 36 +++++++++++ .../telegram/src/bot-native-commands.ts | 63 ++++++++++++------- src/plugins/commands.test.ts | 21 +++++++ src/plugins/commands.ts | 4 ++ 5 files changed, 103 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86a6292aee2..2c6ce16e646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/commands: normalize empty plugin command handler results and let Telegram native plugin commands send the empty-response fallback instead of throwing when a handler returns `undefined`. Fixes #74800. Thanks @vincentkoc. - Plugins/OpenRouter: advertise DeepSeek V4 thinking levels, including `xhigh` and `max`, through the runtime and lightweight provider policy surfaces so `/think` validation no longer rejects OpenRouter-routed DeepSeek V4 models. Fixes #74788. Thanks @vincentkoc. - Status/sessions: ignore malformed non-string persisted session provider/model metadata instead of throwing while rendering status summaries. Thanks @vincentkoc. - CLI/config: remove only the targeted array element for `openclaw config unset array[index]` instead of replaying the unset during config write and deleting the shifted next element. Fixes #76290. Thanks @SymbolStar and @vincentkoc. 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 0ae9598d2aa..7935cdf9cc5 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -1106,4 +1106,40 @@ describe("registerTelegramNativeCommands — session metadata", () => { }), ); }); + + it("sends an empty-response fallback when a plugin command returns undefined", async () => { + pluginRuntimeMocks.executePluginCommand.mockResolvedValue(undefined as never); + + const { handler } = registerAndResolveCommandHandler({ + commandName: "codex", + cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, + useAccessGroups: false, + pluginCommandSpecs: [ + { + name: "codex", + description: "Codex", + acceptsArgs: true, + }, + ] as TelegramPluginCommandSpecs, + }); + pluginRuntimeMocks.matchPluginCommand.mockReturnValue({ + command: { + name: "codex", + description: "Codex", + handler: vi.fn(), + pluginId: "openclaw-codex-app-server", + pluginName: "Codex", + requireAuth: true, + }, + args: "status", + }); + + await handler(createTelegramPrivateCommandContext({ match: "status" })); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [{ text: "No response generated. Please try again." }], + }), + ); + }); }); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 52f6c14cf12..e6fb4fb772c 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -29,6 +29,7 @@ import type { TelegramTopicConfig, } from "openclaw/plugin-sdk/config-types"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; @@ -249,6 +250,16 @@ function resolveTelegramNativeReplyChannelData( return result.channelData?.telegram as TelegramNativeReplyChannelData | undefined; } +function normalizeTelegramNativeReplyPayload( + result: TelegramNativeReplyPayload | null | undefined, +): TelegramNativeReplyPayload { + return result && typeof result === "object" ? result : {}; +} + +function hasRenderableTelegramNativeReplyPayload(result: TelegramNativeReplyPayload): boolean { + return resolveSendableOutboundReplyParts(result).hasContent; +} + function isEditableTelegramProgressResult(result: TelegramNativeReplyPayload): boolean { const telegramData = resolveTelegramNativeReplyChannelData(result); return Boolean( @@ -1276,23 +1287,25 @@ export const registerTelegramNativeCommands = ({ threadId: threadSpec.id, }); - const result = await nativeCommandRuntime.executePluginCommand({ - command: match.command, - args: match.args, - senderId, - channel: "telegram", - isAuthorizedSender: commandAuthorized, - senderIsOwner, - sessionKey: route.sessionKey, - sessionId: sessionFileContext.sessionId, - sessionFile: sessionFileContext.sessionFile, - commandBody, - config: runtimeCfg, - from, - to, - accountId, - messageThreadId: threadSpec.id, - }); + const result = normalizeTelegramNativeReplyPayload( + await nativeCommandRuntime.executePluginCommand({ + command: match.command, + args: match.args, + senderId, + channel: "telegram", + isAuthorizedSender: commandAuthorized, + senderIsOwner, + sessionKey: route.sessionKey, + sessionId: sessionFileContext.sessionId, + sessionFile: sessionFileContext.sessionFile, + commandBody, + config: runtimeCfg, + from, + to, + accountId, + messageThreadId: threadSpec.id, + }), + ); if ( shouldSuppressLocalTelegramExecApprovalPrompt({ @@ -1310,14 +1323,19 @@ export const registerTelegramNativeCommands = ({ return; } + const deliverableResult = hasRenderableTelegramNativeReplyPayload(result) + ? result + : { text: EMPTY_RESPONSE_FALLBACK }; const progressResultText = - typeof result.text === "string" && result.text.trim().length > 0 ? result.text : null; - const telegramResultData = resolveTelegramNativeReplyChannelData(result); + typeof deliverableResult.text === "string" && deliverableResult.text.trim().length > 0 + ? deliverableResult.text + : null; + const telegramResultData = resolveTelegramNativeReplyChannelData(deliverableResult); if ( progressMessageId != null && telegramDeps.editMessageTelegram && progressResultText && - isEditableTelegramProgressResult(result) + isEditableTelegramProgressResult(deliverableResult) ) { try { await telegramDeps.editMessageTelegram(chatId, progressMessageId, progressResultText, { @@ -1350,9 +1368,10 @@ export const registerTelegramNativeCommands = ({ runtime, }); await deliverReplies({ - replies: [result], + replies: [deliverableResult], ...deliveryBaseOptions, - silent: runtimeTelegramCfg.silentErrorReplies === true && result.isError === true, + silent: + runtimeTelegramCfg.silentErrorReplies === true && deliverableResult.isError === true, }); }); } diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 9ed993ee4b9..3a2b964a862 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -901,6 +901,27 @@ describe("registerPluginCommand", () => { }); }); + it("normalizes undefined plugin command handler results to an empty reply payload", async () => { + const handler = async () => undefined as never; + + const result = await executePluginCommand({ + command: { + name: "silentcheck", + description: "Demo command", + acceptsArgs: false, + handler, + pluginId: "demo-plugin", + }, + channel: "telegram", + senderId: "U123", + isAuthorizedSender: true, + commandBody: "/silentcheck", + config: {} as never, + }); + + expect(result).toEqual({}); + }); + it("passes the effective default account to plugin command handlers when accountId is omitted", async () => { setActivePluginRegistry( createTestRegistry([ diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 0b4d3aef04d..bf32ab69168 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -339,6 +339,10 @@ export async function executePluginCommand(params: { logVerbose( `Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`, ); + if (!result || typeof result !== "object") { + logVerbose(`Plugin command /${command.name} returned no reply payload`); + return {}; + } return result; } catch (err) { const error = err as Error;