diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index 5574f6efe89..8ad41e6382f 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -121,10 +121,34 @@ async function dispatchPluginDiscordInteractiveEvent(params: { ? `channel:${params.interactionCtx.channelId}` : `user:${params.interactionCtx.userId}`; let responded = false; + let acknowledged = false; + const updateOriginalMessage = async (input: { + text?: string; + components?: TopLevelComponents[]; + }) => { + const payload = { + ...(input.text !== undefined ? { content: input.text } : {}), + ...(input.components !== undefined ? { components: input.components } : {}), + }; + if (acknowledged) { + // Carbon edits @original on reply() after acknowledge(), which preserves + // plugin edit/clear flows without consuming a second interaction callback. + await params.interaction.reply(payload); + return; + } + if (!("update" in params.interaction) || typeof params.interaction.update !== "function") { + throw new Error("Discord interaction cannot update the source message"); + } + await params.interaction.update(payload); + }; const respond: PluginInteractiveDiscordHandlerContext["respond"] = { acknowledge: async () => { - responded = true; + if (responded) { + return; + } await params.interaction.acknowledge(); + acknowledged = true; + responded = true; }, reply: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => { responded = true; @@ -141,23 +165,17 @@ async function dispatchPluginDiscordInteractiveEvent(params: { }); }, editMessage: async (input) => { - if (!("update" in params.interaction) || typeof params.interaction.update !== "function") { - throw new Error("Discord interaction cannot update the source message"); - } const { text, components } = input; responded = true; - await params.interaction.update({ - ...(text !== undefined ? { content: text } : {}), - ...(components !== undefined ? { components: components as TopLevelComponents[] } : {}), + await updateOriginalMessage({ + text, + components: components as TopLevelComponents[] | undefined, }); }, clearComponents: async (input?: { text?: string }) => { - if (!("update" in params.interaction) || typeof params.interaction.update !== "function") { - throw new Error("Discord interaction cannot clear components on the source message"); - } responded = true; - await params.interaction.update({ - ...(input?.text !== undefined ? { content: input.text } : {}), + await updateOriginalMessage({ + text: input?.text, components: [], }); }, @@ -224,6 +242,13 @@ async function dispatchPluginDiscordInteractiveEvent(params: { }, }, respond, + onMatched: async () => { + try { + await respond.acknowledge(); + } catch { + // Interaction may have expired before the plugin handler ran. + } + }, }); if (!dispatched.matched) { return "unmatched"; diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index ce5d01fb0d8..6bb168cd16b 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -885,6 +885,43 @@ describe("discord component interactions", () => { expect(dispatchReplyMock).not.toHaveBeenCalled(); }); + it("lets plugin Discord interactions clear components after acknowledging", async () => { + registerDiscordComponentEntries({ + entries: [createButtonEntry({ callbackData: "codex:approve" })], + modals: [], + }); + dispatchPluginInteractiveHandlerMock.mockImplementation(async (params: any) => { + await params.respond.acknowledge(); + await params.respond.clearComponents({ text: "Handled" }); + return { + matched: true, + handled: true, + duplicate: false, + }; + }); + + const button = createDiscordComponentButton(createComponentContext()); + const acknowledge = vi.fn().mockResolvedValue(undefined); + const reply = vi.fn().mockResolvedValue(undefined); + const update = vi.fn().mockResolvedValue(undefined); + const interaction = { + ...(createComponentButtonInteraction().interaction as any), + acknowledge, + reply, + update, + } as ButtonInteraction; + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(reply).toHaveBeenCalledWith({ + content: "Handled", + components: [], + }); + expect(update).not.toHaveBeenCalled(); + expect(dispatchReplyMock).not.toHaveBeenCalled(); + }); + it("falls through to built-in Discord component routing when a plugin declines handling", async () => { registerDiscordComponentEntries({ entries: [createButtonEntry({ callbackData: "codex:approve" })], diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index 0cc91e7f04f..7b68969b37a 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -313,6 +313,58 @@ describe("plugin interactive handlers", () => { }); }); + it("acknowledges matched Discord interactions before awaiting plugin handlers", async () => { + const callOrder: string[] = []; + const handler = vi.fn(async () => { + callOrder.push("handler"); + expect(callOrder).toEqual(["ack", "handler"]); + return { handled: true }; + }); + expect( + registerPluginInteractiveHandler("codex-plugin", { + channel: "discord", + namespace: "codex", + handler, + }), + ).toEqual({ ok: true }); + + await expect( + dispatchPluginInteractiveHandler({ + channel: "discord", + data: "codex:approve:thread-1", + interactionId: "ix-ack-1", + ctx: { + accountId: "default", + interactionId: "ix-ack-1", + conversationId: "channel-1", + parentConversationId: "parent-1", + guildId: "guild-1", + senderId: "user-1", + senderUsername: "ada", + auth: { isAuthorizedSender: true }, + interaction: { + kind: "button", + messageId: "message-1", + }, + }, + respond: { + acknowledge: vi.fn(async () => {}), + reply: vi.fn(async () => {}), + followUp: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + clearComponents: vi.fn(async () => {}), + }, + onMatched: async () => { + callOrder.push("ack"); + }, + }), + ).resolves.toEqual({ + matched: true, + handled: true, + duplicate: false, + }); + }); + it("routes Slack interactions by namespace and dedupes interaction ids", async () => { const handler = vi.fn(async () => ({ handled: true })); expect( diff --git a/src/plugins/interactive.ts b/src/plugins/interactive.ts index 424a5c5d0af..b090da43d68 100644 --- a/src/plugins/interactive.ts +++ b/src/plugins/interactive.ts @@ -168,6 +168,7 @@ export async function dispatchPluginInteractiveHandler(params: { clearButtons: () => Promise; deleteMessage: () => Promise; }; + onMatched?: () => Promise | void; }): Promise; export async function dispatchPluginInteractiveHandler(params: { channel: "discord"; @@ -175,6 +176,7 @@ export async function dispatchPluginInteractiveHandler(params: { interactionId: string; ctx: DiscordInteractiveDispatchContext; respond: PluginInteractiveDiscordHandlerContext["respond"]; + onMatched?: () => Promise | void; }): Promise; export async function dispatchPluginInteractiveHandler(params: { channel: "slack"; @@ -182,6 +184,7 @@ export async function dispatchPluginInteractiveHandler(params: { interactionId: string; ctx: SlackInteractiveDispatchContext; respond: PluginInteractiveSlackHandlerContext["respond"]; + onMatched?: () => Promise | void; }): Promise; export async function dispatchPluginInteractiveHandler(params: { channel: "telegram" | "discord" | "slack"; @@ -205,6 +208,7 @@ export async function dispatchPluginInteractiveHandler(params: { } | PluginInteractiveDiscordHandlerContext["respond"] | PluginInteractiveSlackHandlerContext["respond"]; + onMatched?: () => Promise | void; }): Promise { const match = resolveNamespaceMatch(params.channel, params.data); if (!match) { @@ -217,6 +221,8 @@ export async function dispatchPluginInteractiveHandler(params: { return { matched: true, handled: true, duplicate: true }; } + await params.onMatched?.(); + let result: | ReturnType | ReturnType