fix: warn on empty discord native command replies

This commit is contained in:
Peter Steinberger
2026-05-02 12:14:30 +01:00
parent 5d0925fbb2
commit 460a5c131f
5 changed files with 90 additions and 2 deletions

View File

@@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai
- Hooks/doctor: warn when `hooks.transformsDir` points outside the canonical hooks transform directory, so invalid workspace skill paths get a direct recovery hint before the Gateway crash-loops. Fixes #75853. Thanks @midobk.
- Proxy/audio: convert standard `FormData` bodies before proxy-backed undici fetches, so audio transcription and multipart uploads no longer send `[object FormData]` when `HTTP_PROXY` or `HTTPS_PROXY` is configured. Fixes #48554. Thanks @dco5.
- Discord: allow explicitly configured ack reactions in tool-only guild channels while keeping automatic lifecycle/status reactions suppressed. Fixes #74922. Thanks @samvilian and @BlueBirdBack.
- Discord/native commands: return an explicit warning when slash command dispatch or direct plugin execution produces no visible reply instead of a success-style completion ack. Fixes #58986; supersedes #62057. Thanks @jb510.
- Discord: keep typing indicators alive during long tool runs and auto-compaction while keepalive ticks continue, so active sessions do not appear stalled before the final reply. Thanks @Squirbie.
- Discord: preserve multipart Content-Type headers for attachment uploads across REST fetch paths, so generated images and other media no longer fail delivery with `CONTENT_TYPE_INVALID`. Thanks @FunJim.
- Discord: preserve attachment and sticker filenames when saving inbound media, so agents can see human-readable file names instead of only UUID-based paths. Fixes #59744. Thanks @xela92 and @rockcent.

View File

@@ -15,6 +15,7 @@ import type {
import type { DiscordChannelConfigResolved } from "./allow-list.js";
import type { buildDiscordNativeCommandContext } from "./native-command-context.js";
import {
DISCORD_EMPTY_VISIBLE_REPLY_WARNING,
deliverDiscordInteractionReply,
isDiscordUnknownInteraction,
safeDiscordInteractionCall,
@@ -102,6 +103,7 @@ export async function dispatchDiscordNativeAgentReply(params: {
if (
params.suppressReplies ||
didReply ||
dispatchResult.queuedFinal ||
dispatchResult.counts.final !== 0 ||
dispatchResult.counts.block !== 0 ||
dispatchResult.counts.tool !== 0
@@ -111,7 +113,7 @@ export async function dispatchDiscordNativeAgentReply(params: {
await safeDiscordInteractionCall("interaction empty fallback", async () => {
const payload = {
content: "✅ Done.",
content: DISCORD_EMPTY_VISIBLE_REPLY_WARNING,
ephemeral: true,
};
if (params.preferFollowUp) {

View File

@@ -13,6 +13,8 @@ import type {
TopLevelComponents,
} from "../internal/discord.js";
export const DISCORD_EMPTY_VISIBLE_REPLY_WARNING = "⚠️ Command produced no visible reply.";
export function isDiscordUnknownInteraction(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;

View File

@@ -529,6 +529,88 @@ describe("Discord native plugin command dispatch", () => {
expect(interaction.reply).not.toHaveBeenCalled();
});
it("returns an explicit warning instead of success when dispatch produces zero visible replies", async () => {
const cfg = createConfig();
const interaction = createInteraction();
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue({
counts: { final: 0, block: 0, tool: 0 },
queuedFinal: false,
} as never);
const command = await createNativeCommand(cfg, {
name: "new",
description: "Start a new session.",
acceptsArgs: true,
});
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
expect(interaction.followUp).toHaveBeenCalledWith(
expect.objectContaining({
content: "⚠️ Command produced no visible reply.",
ephemeral: true,
}),
);
expect(interaction.reply).not.toHaveBeenCalled();
});
it("does not warn when dispatch reports a queued final without visible counts", async () => {
const cfg = createConfig();
const interaction = createInteraction();
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue({
counts: { final: 0, block: 0, tool: 0 },
queuedFinal: true,
} as never);
const command = await createNativeCommand(cfg, {
name: "new",
description: "Start a new session.",
acceptsArgs: true,
});
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
expect(interaction.followUp).not.toHaveBeenCalledWith(
expect.objectContaining({ content: "⚠️ Command produced no visible reply." }),
);
expect(interaction.reply).not.toHaveBeenCalled();
});
it("returns an explicit warning when a direct plugin command has no visible reply", async () => {
const cfg = createConfig();
const commandSpec: NativeCommandSpec = {
name: "cron_jobs",
description: "List cron jobs",
acceptsArgs: false,
};
const interaction = createInteraction();
const pluginMatch = {
command: {
name: "cron_jobs",
description: "List cron jobs",
pluginId: "cron-jobs",
acceptsArgs: false,
handler: vi.fn().mockResolvedValue({ text: "" }),
},
args: undefined,
};
runtimeModuleMocks.matchPluginCommand.mockReturnValue(pluginMatch as never);
runtimeModuleMocks.executePluginCommand.mockResolvedValue({});
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue(
{} as never,
);
const command = await createNativeCommand(cfg, commandSpec);
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
expect(dispatchSpy).not.toHaveBeenCalled();
expect(interaction.followUp).toHaveBeenCalledWith(
expect.objectContaining({ content: "⚠️ Command produced no visible reply." }),
);
expect(interaction.reply).not.toHaveBeenCalled();
});
it("forwards Discord thread metadata into direct plugin command execution", async () => {
const cfg = {
commands: {

View File

@@ -53,6 +53,7 @@ import {
import { buildDiscordNativeCommandContext } from "./native-command-context.js";
import type { DispatchDiscordCommandInteractionResult } from "./native-command-dispatch.js";
import {
DISCORD_EMPTY_VISIBLE_REPLY_WARNING,
deliverDiscordInteractionReply,
hasRenderableReplyPayload,
safeDiscordInteractionCall,
@@ -540,7 +541,7 @@ async function dispatchDiscordCommandInteraction(params: {
threadParentId: pluginThreadParentId,
});
if (!hasRenderableReplyPayload(pluginReply)) {
await respond("Done.");
await respond(DISCORD_EMPTY_VISIBLE_REPLY_WARNING);
return { accepted: true, effectiveRoute };
}
await deliverDiscordInteractionReply({