diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index b9cdfa38a249..84ae7bed680f 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -183,21 +183,21 @@ generic contracts; Plan Mode can use them, but so can approval workflows, workspace policy gates, background monitors, setup wizards, and UI companion plugins. -| Method | Contract it owns | -| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | -| `api.session.state.registerSessionExtension(...)` | Plugin-owned, JSON-compatible session state projected through Gateway sessions | -| `api.session.workflow.enqueueNextTurnInjection(...)` | Durable exactly-once context injected into the next agent turn for one session | -| `api.registerTrustedToolPolicy(...)` | Manifest-gated trusted pre-plugin tool policy that can block or rewrite tool params | -| `api.registerToolMetadata(...)` | Tool catalog display metadata without changing the tool implementation | +| Method | Contract it owns | +| ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `api.session.state.registerSessionExtension(...)` | Plugin-owned, JSON-compatible session state projected through Gateway sessions | +| `api.session.workflow.enqueueNextTurnInjection(...)` | Durable exactly-once context injected into the next agent turn for one session | +| `api.registerTrustedToolPolicy(...)` | Manifest-gated trusted pre-plugin tool policy that can block or rewrite tool params | +| `api.registerToolMetadata(...)` | Tool catalog display metadata without changing the tool implementation | | `api.registerCommand(...)` | Scoped plugin commands; command results can set `continueAgent: true` or `suppressReply: true`; Discord native commands support `descriptionLocalizations` | -| `api.session.controls.registerControlUiDescriptor(...)` | Control UI contribution descriptors for session, tool, run, or settings surfaces | -| `api.lifecycle.registerRuntimeLifecycle(...)` | Cleanup callbacks for plugin-owned runtime resources on reset/delete/reload paths | -| `api.agent.events.registerAgentEventSubscription(...)` | Sanitized event subscriptions for workflow state and monitors | -| `api.runContext.setRunContext(...)` / `getRunContext(...)` / `clearRunContext(...)` | Per-run plugin scratch state cleared on terminal run lifecycle | -| `api.session.workflow.registerSessionSchedulerJob(...)` | Cleanup metadata for plugin-owned scheduler jobs; does not schedule work or create task records | -| `api.session.workflow.sendSessionAttachment(...)` | Bundled-only host-mediated file attachment delivery to the active direct-outbound session route | -| `api.session.workflow.scheduleSessionTurn(...)` / `unscheduleSessionTurnsByTag(...)` | Bundled-only Cron-backed scheduled session turns plus tag-based cleanup | -| `api.session.controls.registerSessionAction(...)` | Typed session actions clients can dispatch through the Gateway | +| `api.session.controls.registerControlUiDescriptor(...)` | Control UI contribution descriptors for session, tool, run, or settings surfaces | +| `api.lifecycle.registerRuntimeLifecycle(...)` | Cleanup callbacks for plugin-owned runtime resources on reset/delete/reload paths | +| `api.agent.events.registerAgentEventSubscription(...)` | Sanitized event subscriptions for workflow state and monitors | +| `api.runContext.setRunContext(...)` / `getRunContext(...)` / `clearRunContext(...)` | Per-run plugin scratch state cleared on terminal run lifecycle | +| `api.session.workflow.registerSessionSchedulerJob(...)` | Cleanup metadata for plugin-owned scheduler jobs; does not schedule work or create task records | +| `api.session.workflow.sendSessionAttachment(...)` | Bundled-only host-mediated file attachment delivery to the active direct-outbound session route | +| `api.session.workflow.scheduleSessionTurn(...)` / `unscheduleSessionTurnsByTag(...)` | Bundled-only Cron-backed scheduled session turns plus tag-based cleanup | +| `api.session.controls.registerSessionAction(...)` | Typed session actions clients can dispatch through the Gateway | Use the grouped namespaces for new plugin code: 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 1a6d8d570afc..1a2de24ffec3 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -1020,6 +1020,39 @@ describe("Discord native plugin command dispatch", () => { expect(interaction.reply).not.toHaveBeenCalled(); }); + it("suppresses the warning when a direct plugin command suppresses replies", 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({ suppressReply: true }), + }, + args: undefined, + }; + + runtimeModuleMocks.matchPluginCommand.mockReturnValue(pluginMatch as never); + runtimeModuleMocks.executePluginCommand.mockResolvedValue({ suppressReply: true }); + 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(); + expectNoFollowUpContent(interaction, "⚠️ 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 15a579942489..3756fae4aee8 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -573,6 +573,9 @@ async function dispatchDiscordCommandInteraction(params: { messageThreadId, threadParentId: pluginThreadParentId, }); + if (pluginReply.suppressReply === true) { + return { accepted: true, effectiveRoute }; + } if (!hasRenderableReplyPayload(pluginReply)) { await respond(DISCORD_EMPTY_VISIBLE_REPLY_WARNING); return { accepted: true, effectiveRoute }; diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 3ea8d96ce2a8..f737622663cb 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -116,7 +116,7 @@ type TelegramNativeCommandContext = Context & { match?: string }; type TelegramChunkMode = ReturnType< typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").resolveChunkMode >; -type TelegramNativeReplyPayload = import("openclaw/plugin-sdk/reply-dispatch-runtime").ReplyPayload; +type TelegramNativeReplyPayload = import("openclaw/plugin-sdk/plugin-entry").PluginCommandResult; type TelegramNativeReplyChannelData = { buttons?: TelegramInlineButtons; pin?: boolean; @@ -458,9 +458,7 @@ function normalizeTelegramNativeReplyPayload( } function isSuppressedTelegramNativeReplyPayload(result: TelegramNativeReplyPayload): boolean { - return Boolean( - (result as TelegramNativeReplyPayload & { suppressReply?: boolean }).suppressReply, - ); + return result.suppressReply === true; } function hasRenderableTelegramNativeReplyPayload(result: TelegramNativeReplyPayload): boolean { @@ -1657,24 +1655,13 @@ export const registerTelegramNativeCommands = ({ }), ); - if ( + const suppressTelegramNativeReply = shouldSuppressLocalTelegramExecApprovalPrompt({ cfg: runtimeCfg, accountId: route.accountId, payload: result, - }) - ) { - await cleanupTelegramProgressPlaceholder({ - bot, - chatId, - progressMessageId, - runtime, - }); - return; - } - - // If the plugin handled delivery itself and wants no fallback, just clean up - if (isSuppressedTelegramNativeReplyPayload(result)) { + }) || isSuppressedTelegramNativeReplyPayload(result); + if (suppressTelegramNativeReply) { await cleanupTelegramProgressPlaceholder({ bot, chatId, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 846fc9540ece..675f8021a483 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -2025,15 +2025,9 @@ export type PluginCommandContext = { * Result returned by a plugin command handler. */ export type PluginCommandResult = ReplyPayload & { - /** When true, allows the agent session to continue processing after the command */ + /** Allows the agent session to continue processing after the command. */ continueAgent?: boolean; - /** - * When true, the channel adapter should not send a fallback reply. - * Use this when the plugin command handler delivers its own response - * directly via the channel API (e.g. Telegram Bot API with custom - * retry logic or transport guarantees) instead of returning a payload - * for OpenClaw to deliver. - */ + /** Suppresses channel fallback replies when the handler already delivered a response. */ suppressReply?: boolean; };