Skip to content

Commit a5e11c0

Browse files
committed
fix(plugin-sdk): honor suppressReply across native commands
1 parent d616206 commit a5e11c0

5 files changed

Lines changed: 57 additions & 40 deletions

File tree

docs/plugins/sdk-overview.md

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -183,21 +183,21 @@ generic contracts; Plan Mode can use them, but so can approval workflows,
183183
workspace policy gates, background monitors, setup wizards, and UI companion
184184
plugins.
185185

186-
| Method | Contract it owns |
187-
| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
188-
| `api.session.state.registerSessionExtension(...)` | Plugin-owned, JSON-compatible session state projected through Gateway sessions |
189-
| `api.session.workflow.enqueueNextTurnInjection(...)` | Durable exactly-once context injected into the next agent turn for one session |
190-
| `api.registerTrustedToolPolicy(...)` | Manifest-gated trusted pre-plugin tool policy that can block or rewrite tool params |
191-
| `api.registerToolMetadata(...)` | Tool catalog display metadata without changing the tool implementation |
186+
| Method | Contract it owns |
187+
| ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
188+
| `api.session.state.registerSessionExtension(...)` | Plugin-owned, JSON-compatible session state projected through Gateway sessions |
189+
| `api.session.workflow.enqueueNextTurnInjection(...)` | Durable exactly-once context injected into the next agent turn for one session |
190+
| `api.registerTrustedToolPolicy(...)` | Manifest-gated trusted pre-plugin tool policy that can block or rewrite tool params |
191+
| `api.registerToolMetadata(...)` | Tool catalog display metadata without changing the tool implementation |
192192
| `api.registerCommand(...)` | Scoped plugin commands; command results can set `continueAgent: true` or `suppressReply: true`; Discord native commands support `descriptionLocalizations` |
193-
| `api.session.controls.registerControlUiDescriptor(...)` | Control UI contribution descriptors for session, tool, run, or settings surfaces |
194-
| `api.lifecycle.registerRuntimeLifecycle(...)` | Cleanup callbacks for plugin-owned runtime resources on reset/delete/reload paths |
195-
| `api.agent.events.registerAgentEventSubscription(...)` | Sanitized event subscriptions for workflow state and monitors |
196-
| `api.runContext.setRunContext(...)` / `getRunContext(...)` / `clearRunContext(...)` | Per-run plugin scratch state cleared on terminal run lifecycle |
197-
| `api.session.workflow.registerSessionSchedulerJob(...)` | Cleanup metadata for plugin-owned scheduler jobs; does not schedule work or create task records |
198-
| `api.session.workflow.sendSessionAttachment(...)` | Bundled-only host-mediated file attachment delivery to the active direct-outbound session route |
199-
| `api.session.workflow.scheduleSessionTurn(...)` / `unscheduleSessionTurnsByTag(...)` | Bundled-only Cron-backed scheduled session turns plus tag-based cleanup |
200-
| `api.session.controls.registerSessionAction(...)` | Typed session actions clients can dispatch through the Gateway |
193+
| `api.session.controls.registerControlUiDescriptor(...)` | Control UI contribution descriptors for session, tool, run, or settings surfaces |
194+
| `api.lifecycle.registerRuntimeLifecycle(...)` | Cleanup callbacks for plugin-owned runtime resources on reset/delete/reload paths |
195+
| `api.agent.events.registerAgentEventSubscription(...)` | Sanitized event subscriptions for workflow state and monitors |
196+
| `api.runContext.setRunContext(...)` / `getRunContext(...)` / `clearRunContext(...)` | Per-run plugin scratch state cleared on terminal run lifecycle |
197+
| `api.session.workflow.registerSessionSchedulerJob(...)` | Cleanup metadata for plugin-owned scheduler jobs; does not schedule work or create task records |
198+
| `api.session.workflow.sendSessionAttachment(...)` | Bundled-only host-mediated file attachment delivery to the active direct-outbound session route |
199+
| `api.session.workflow.scheduleSessionTurn(...)` / `unscheduleSessionTurnsByTag(...)` | Bundled-only Cron-backed scheduled session turns plus tag-based cleanup |
200+
| `api.session.controls.registerSessionAction(...)` | Typed session actions clients can dispatch through the Gateway |
201201

202202
Use the grouped namespaces for new plugin code:
203203

extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,39 @@ describe("Discord native plugin command dispatch", () => {
10201020
expect(interaction.reply).not.toHaveBeenCalled();
10211021
});
10221022

1023+
it("suppresses the warning when a direct plugin command suppresses replies", async () => {
1024+
const cfg = createConfig();
1025+
const commandSpec: NativeCommandSpec = {
1026+
name: "cron_jobs",
1027+
description: "List cron jobs",
1028+
acceptsArgs: false,
1029+
};
1030+
const interaction = createInteraction();
1031+
const pluginMatch = {
1032+
command: {
1033+
name: "cron_jobs",
1034+
description: "List cron jobs",
1035+
pluginId: "cron-jobs",
1036+
acceptsArgs: false,
1037+
handler: vi.fn().mockResolvedValue({ suppressReply: true }),
1038+
},
1039+
args: undefined,
1040+
};
1041+
1042+
runtimeModuleMocks.matchPluginCommand.mockReturnValue(pluginMatch as never);
1043+
runtimeModuleMocks.executePluginCommand.mockResolvedValue({ suppressReply: true });
1044+
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue(
1045+
{} as never,
1046+
);
1047+
const command = await createNativeCommand(cfg, commandSpec);
1048+
1049+
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
1050+
1051+
expect(dispatchSpy).not.toHaveBeenCalled();
1052+
expectNoFollowUpContent(interaction, "⚠️ Command produced no visible reply.");
1053+
expect(interaction.reply).not.toHaveBeenCalled();
1054+
});
1055+
10231056
it("forwards Discord thread metadata into direct plugin command execution", async () => {
10241057
const cfg = {
10251058
commands: {

extensions/discord/src/monitor/native-command.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,9 @@ async function dispatchDiscordCommandInteraction(params: {
573573
messageThreadId,
574574
threadParentId: pluginThreadParentId,
575575
});
576+
if (pluginReply.suppressReply === true) {
577+
return { accepted: true, effectiveRoute };
578+
}
576579
if (!hasRenderableReplyPayload(pluginReply)) {
577580
await respond(DISCORD_EMPTY_VISIBLE_REPLY_WARNING);
578581
return { accepted: true, effectiveRoute };

extensions/telegram/src/bot-native-commands.ts

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ type TelegramNativeCommandContext = Context & { match?: string };
116116
type TelegramChunkMode = ReturnType<
117117
typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").resolveChunkMode
118118
>;
119-
type TelegramNativeReplyPayload = import("openclaw/plugin-sdk/reply-dispatch-runtime").ReplyPayload;
119+
type TelegramNativeReplyPayload = import("openclaw/plugin-sdk/plugin-entry").PluginCommandResult;
120120
type TelegramNativeReplyChannelData = {
121121
buttons?: TelegramInlineButtons;
122122
pin?: boolean;
@@ -458,9 +458,7 @@ function normalizeTelegramNativeReplyPayload(
458458
}
459459

460460
function isSuppressedTelegramNativeReplyPayload(result: TelegramNativeReplyPayload): boolean {
461-
return Boolean(
462-
(result as TelegramNativeReplyPayload & { suppressReply?: boolean }).suppressReply,
463-
);
461+
return result.suppressReply === true;
464462
}
465463

466464
function hasRenderableTelegramNativeReplyPayload(result: TelegramNativeReplyPayload): boolean {
@@ -1657,24 +1655,13 @@ export const registerTelegramNativeCommands = ({
16571655
}),
16581656
);
16591657

1660-
if (
1658+
const suppressTelegramNativeReply =
16611659
shouldSuppressLocalTelegramExecApprovalPrompt({
16621660
cfg: runtimeCfg,
16631661
accountId: route.accountId,
16641662
payload: result,
1665-
})
1666-
) {
1667-
await cleanupTelegramProgressPlaceholder({
1668-
bot,
1669-
chatId,
1670-
progressMessageId,
1671-
runtime,
1672-
});
1673-
return;
1674-
}
1675-
1676-
// If the plugin handled delivery itself and wants no fallback, just clean up
1677-
if (isSuppressedTelegramNativeReplyPayload(result)) {
1663+
}) || isSuppressedTelegramNativeReplyPayload(result);
1664+
if (suppressTelegramNativeReply) {
16781665
await cleanupTelegramProgressPlaceholder({
16791666
bot,
16801667
chatId,

src/plugins/types.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2025,15 +2025,9 @@ export type PluginCommandContext = {
20252025
* Result returned by a plugin command handler.
20262026
*/
20272027
export type PluginCommandResult = ReplyPayload & {
2028-
/** When true, allows the agent session to continue processing after the command */
2028+
/** Allows the agent session to continue processing after the command. */
20292029
continueAgent?: boolean;
2030-
/**
2031-
* When true, the channel adapter should not send a fallback reply.
2032-
* Use this when the plugin command handler delivers its own response
2033-
* directly via the channel API (e.g. Telegram Bot API with custom
2034-
* retry logic or transport guarantees) instead of returning a payload
2035-
* for OpenClaw to deliver.
2036-
*/
2030+
/** Suppresses channel fallback replies when the handler already delivered a response. */
20372031
suppressReply?: boolean;
20382032
};
20392033

0 commit comments

Comments
 (0)