From 078642b3088b4373a6438792c3aef18e056d50e8 Mon Sep 17 00:00:00 2001 From: Robby Date: Sat, 14 Feb 2026 17:38:01 +0100 Subject: [PATCH] fix(discord): defer component interactions to prevent timeout (#16287) * fix(discord): defer component interactions to prevent timeout Discord requires interaction responses within 3 seconds. Button clicks were routed through the LLM pipeline before responding, exceeding this window and showing 'This interaction failed' to users. Now immediately defers the interaction, then processes the agent response asynchronously. Fixes #16262 * fix: harden deferred interaction replies and silent chat finals (#16287) (thanks @robbyczgw-cla) --------- Co-authored-by: Peter Steinberger --- src/discord/monitor/agent-components.test.ts | 21 ++++++---- src/discord/monitor/agent-components.ts | 43 ++++++++++++++++---- src/tui/components/chat-log.ts | 10 +++++ src/tui/tui-event-handlers.test.ts | 37 ++++++++++++++++- src/tui/tui-event-handlers.ts | 14 +++++++ 5 files changed, 109 insertions(+), 16 deletions(-) diff --git a/src/discord/monitor/agent-components.test.ts b/src/discord/monitor/agent-components.test.ts index f3b1e98c229..ea19695dc63 100644 --- a/src/discord/monitor/agent-components.test.ts +++ b/src/discord/monitor/agent-components.test.ts @@ -24,25 +24,29 @@ const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig; const createDmButtonInteraction = (overrides: Partial = {}) => { const reply = vi.fn().mockResolvedValue(undefined); + const defer = vi.fn().mockResolvedValue(undefined); const interaction = { rawData: { channel_id: "dm-channel" }, user: { id: "123456789", username: "Alice", discriminator: "1234" }, + defer, reply, ...overrides, } as unknown as ButtonInteraction; - return { interaction, reply }; + return { interaction, defer, reply }; }; const createDmSelectInteraction = (overrides: Partial = {}) => { const reply = vi.fn().mockResolvedValue(undefined); + const defer = vi.fn().mockResolvedValue(undefined); const interaction = { rawData: { channel_id: "dm-channel" }, user: { id: "123456789", username: "Alice", discriminator: "1234" }, values: ["alpha"], + defer, reply, ...overrides, } as unknown as StringSelectMenuInteraction; - return { interaction, reply }; + return { interaction, defer, reply }; }; beforeEach(() => { @@ -58,10 +62,11 @@ describe("agent components", () => { accountId: "default", dmPolicy: "pairing", }); - const { interaction, reply } = createDmButtonInteraction(); + const { interaction, defer, reply } = createDmButtonInteraction(); await button.run(interaction, { componentId: "hello" } as ComponentData); + expect(defer).toHaveBeenCalledWith({ ephemeral: true }); expect(reply).toHaveBeenCalledTimes(1); expect(reply.mock.calls[0]?.[0]?.content).toContain("Pairing code: PAIRCODE"); expect(enqueueSystemEventMock).not.toHaveBeenCalled(); @@ -74,11 +79,12 @@ describe("agent components", () => { accountId: "default", dmPolicy: "allowlist", }); - const { interaction, reply } = createDmButtonInteraction(); + const { interaction, defer, reply } = createDmButtonInteraction(); await button.run(interaction, { componentId: "hello" } as ComponentData); - expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true }); + expect(defer).toHaveBeenCalledWith({ ephemeral: true }); + expect(reply).toHaveBeenCalledWith({ content: "✓" }); expect(enqueueSystemEventMock).toHaveBeenCalled(); }); @@ -89,11 +95,12 @@ describe("agent components", () => { dmPolicy: "allowlist", allowFrom: ["Alice#1234"], }); - const { interaction, reply } = createDmSelectInteraction(); + const { interaction, defer, reply } = createDmSelectInteraction(); await select.run(interaction, { componentId: "hello" } as ComponentData); - expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true }); + expect(defer).toHaveBeenCalledWith({ ephemeral: true }); + expect(reply).toHaveBeenCalledWith({ content: "✓" }); expect(enqueueSystemEventMock).toHaveBeenCalled(); }); }); diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index 10c31918b87..028568a7e7d 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -108,15 +108,16 @@ async function ensureDmComponentAuthorized(params: { interaction: AgentComponentInteraction; user: DiscordUser; componentLabel: string; + replyOpts: { ephemeral?: boolean }; }): Promise { - const { ctx, interaction, user, componentLabel } = params; + const { ctx, interaction, user, componentLabel, replyOpts } = params; const dmPolicy = ctx.dmPolicy ?? "pairing"; if (dmPolicy === "disabled") { logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`); try { await interaction.reply({ content: "DM interactions are disabled.", - ephemeral: true, + ...replyOpts, }); } catch { // Interaction may have expired @@ -162,7 +163,7 @@ async function ensureDmComponentAuthorized(params: { code, }) : "Pairing already requested. Ask the bot owner to approve your code.", - ephemeral: true, + ...replyOpts, }); } catch { // Interaction may have expired @@ -174,7 +175,7 @@ async function ensureDmComponentAuthorized(params: { try { await interaction.reply({ content: `You are not authorized to use this ${componentLabel}.`, - ephemeral: true, + ...replyOpts, }); } catch { // Interaction may have expired @@ -226,6 +227,18 @@ export class AgentComponentButton extends Button { return; } + let didDefer = false; + // Defer immediately to satisfy Discord's 3-second interaction ACK requirement. + // We use an ephemeral deferred reply so subsequent interaction.reply() calls + // can safely edit the original deferred response. + try { + await interaction.defer({ ephemeral: true }); + didDefer = true; + } catch (err) { + logError(`agent button: failed to defer interaction: ${String(err)}`); + } + const replyOpts = didDefer ? {} : { ephemeral: true }; + const username = formatUsername(user); const userId = user.id; @@ -243,6 +256,7 @@ export class AgentComponentButton extends Button { interaction, user, componentLabel: "button", + replyOpts, }); if (!authorized) { return; @@ -311,7 +325,7 @@ export class AgentComponentButton extends Button { try { await interaction.reply({ content: "You are not authorized to use this button.", - ephemeral: true, + ...replyOpts, }); } catch { // Interaction may have expired @@ -347,7 +361,7 @@ export class AgentComponentButton extends Button { try { await interaction.reply({ content: "✓", - ephemeral: true, + ...replyOpts, }); } catch (err) { logError(`agent button: failed to acknowledge interaction: ${String(err)}`); @@ -397,6 +411,18 @@ export class AgentSelectMenu extends StringSelectMenu { return; } + let didDefer = false; + // Defer immediately to satisfy Discord's 3-second interaction ACK requirement. + // We use an ephemeral deferred reply so subsequent interaction.reply() calls + // can safely edit the original deferred response. + try { + await interaction.defer({ ephemeral: true }); + didDefer = true; + } catch (err) { + logError(`agent select: failed to defer interaction: ${String(err)}`); + } + const replyOpts = didDefer ? {} : { ephemeral: true }; + const username = formatUsername(user); const userId = user.id; @@ -414,6 +440,7 @@ export class AgentSelectMenu extends StringSelectMenu { interaction, user, componentLabel: "select menu", + replyOpts, }); if (!authorized) { return; @@ -478,7 +505,7 @@ export class AgentSelectMenu extends StringSelectMenu { try { await interaction.reply({ content: "You are not authorized to use this select menu.", - ephemeral: true, + ...replyOpts, }); } catch { // Interaction may have expired @@ -518,7 +545,7 @@ export class AgentSelectMenu extends StringSelectMenu { try { await interaction.reply({ content: "✓", - ephemeral: true, + ...replyOpts, }); } catch (err) { logError(`agent select: failed to acknowledge interaction: ${String(err)}`); diff --git a/src/tui/components/chat-log.ts b/src/tui/components/chat-log.ts index e9c696397ef..926b32c661a 100644 --- a/src/tui/components/chat-log.ts +++ b/src/tui/components/chat-log.ts @@ -56,6 +56,16 @@ export class ChatLog extends Container { this.addChild(new AssistantMessageComponent(text)); } + dropAssistant(runId?: string) { + const effectiveRunId = this.resolveRunId(runId); + const existing = this.streamingRuns.get(effectiveRunId); + if (!existing) { + return; + } + this.removeChild(existing); + this.streamingRuns.delete(effectiveRunId); + } + startTool(toolCallId: string, toolName: string, args: unknown) { const existing = this.toolById.get(toolCallId); if (existing) { diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 98b3d24da21..352af6af786 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -6,7 +6,12 @@ import { createEventHandlers } from "./tui-event-handlers.js"; type MockChatLog = Pick< ChatLog, - "startTool" | "updateToolResult" | "addSystem" | "updateAssistant" | "finalizeAssistant" + | "startTool" + | "updateToolResult" + | "addSystem" + | "updateAssistant" + | "finalizeAssistant" + | "dropAssistant" >; type MockTui = Pick; @@ -41,6 +46,7 @@ describe("tui-event-handlers: handleAgentEvent", () => { addSystem: vi.fn(), updateAssistant: vi.fn(), finalizeAssistant: vi.fn(), + dropAssistant: vi.fn(), }; const tui: MockTui = { requestRender: vi.fn() }; const setActivityStatus = vi.fn(); @@ -357,4 +363,33 @@ describe("tui-event-handlers: handleAgentEvent", () => { expect(loadHistory).toHaveBeenCalledTimes(1); }); + + it("drops streaming assistant when chat final has no message", () => { + const state = makeState({ activeChatRunId: null }); + const { chatLog, tui, setActivityStatus } = makeContext(state); + const { handleChatEvent } = createEventHandlers({ + chatLog, + tui, + state, + setActivityStatus, + }); + + handleChatEvent({ + runId: "run-silent", + sessionKey: state.currentSessionKey, + state: "delta", + message: { content: "hello" }, + }); + chatLog.dropAssistant.mockClear(); + chatLog.finalizeAssistant.mockClear(); + + handleChatEvent({ + runId: "run-silent", + sessionKey: state.currentSessionKey, + state: "final", + }); + + expect(chatLog.dropAssistant).toHaveBeenCalledWith("run-silent"); + expect(chatLog.finalizeAssistant).not.toHaveBeenCalled(); + }); }); diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index c07aef99a69..743a62a5058 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -109,6 +109,20 @@ export function createEventHandlers(context: EventHandlerContext) { setActivityStatus("streaming"); } if (evt.state === "final") { + if (!evt.message) { + if (isLocalRunId?.(evt.runId)) { + forgetLocalRunId?.(evt.runId); + } else { + void loadHistory?.(); + } + chatLog.dropAssistant(evt.runId); + noteFinalizedRun(evt.runId); + state.activeChatRunId = null; + setActivityStatus("idle"); + void refreshSessionInfo?.(); + tui.requestRender(); + return; + } if (isCommandMessage(evt.message)) { if (isLocalRunId?.(evt.runId)) { forgetLocalRunId?.(evt.runId);