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);