diff --git a/CHANGELOG.md b/CHANGELOG.md index 464ac74ec95..058eb7907ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/discord/src/monitor/native-command-agent-reply.ts b/extensions/discord/src/monitor/native-command-agent-reply.ts index 1ccf3440684..bfdb9e4294a 100644 --- a/extensions/discord/src/monitor/native-command-agent-reply.ts +++ b/extensions/discord/src/monitor/native-command-agent-reply.ts @@ -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) { diff --git a/extensions/discord/src/monitor/native-command-reply.ts b/extensions/discord/src/monitor/native-command-reply.ts index f3f666453de..64d2cd1d691 100644 --- a/extensions/discord/src/monitor/native-command-reply.ts +++ b/extensions/discord/src/monitor/native-command-reply.ts @@ -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; diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index 555cc7e7480..dbd7bd65b56 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -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 }).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 }).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 }).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: { diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index f759f5b2213..ad4527577f6 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -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({