fix(commands): tolerate empty plugin command replies

Fixes #74800.
This commit is contained in:
Vincent Koc
2026-05-03 10:55:58 -07:00
parent 63ebe372e8
commit 863198f0c9
5 changed files with 103 additions and 22 deletions

View File

@@ -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.

View File

@@ -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." }],
}),
);
});
});

View File

@@ -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,
});
});
}

View File

@@ -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([

View File

@@ -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;