fix(plugin-sdk): honor suppressReply across native commands

This commit is contained in:
Ayaan Zaidi
2026-07-01 09:01:59 -07:00
parent d616206c58
commit a5e11c0083
5 changed files with 57 additions and 40 deletions
+14 -14
View File
@@ -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:
@@ -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<void> }).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: {
@@ -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 };
+5 -18
View File
@@ -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,
+2 -8
View File
@@ -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;
};