mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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 <steipete@gmail.com>
This commit is contained in:
@@ -24,25 +24,29 @@ const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
|
|||||||
|
|
||||||
const createDmButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
|
const createDmButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
|
||||||
const reply = vi.fn().mockResolvedValue(undefined);
|
const reply = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const defer = vi.fn().mockResolvedValue(undefined);
|
||||||
const interaction = {
|
const interaction = {
|
||||||
rawData: { channel_id: "dm-channel" },
|
rawData: { channel_id: "dm-channel" },
|
||||||
user: { id: "123456789", username: "Alice", discriminator: "1234" },
|
user: { id: "123456789", username: "Alice", discriminator: "1234" },
|
||||||
|
defer,
|
||||||
reply,
|
reply,
|
||||||
...overrides,
|
...overrides,
|
||||||
} as unknown as ButtonInteraction;
|
} as unknown as ButtonInteraction;
|
||||||
return { interaction, reply };
|
return { interaction, defer, reply };
|
||||||
};
|
};
|
||||||
|
|
||||||
const createDmSelectInteraction = (overrides: Partial<StringSelectMenuInteraction> = {}) => {
|
const createDmSelectInteraction = (overrides: Partial<StringSelectMenuInteraction> = {}) => {
|
||||||
const reply = vi.fn().mockResolvedValue(undefined);
|
const reply = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const defer = vi.fn().mockResolvedValue(undefined);
|
||||||
const interaction = {
|
const interaction = {
|
||||||
rawData: { channel_id: "dm-channel" },
|
rawData: { channel_id: "dm-channel" },
|
||||||
user: { id: "123456789", username: "Alice", discriminator: "1234" },
|
user: { id: "123456789", username: "Alice", discriminator: "1234" },
|
||||||
values: ["alpha"],
|
values: ["alpha"],
|
||||||
|
defer,
|
||||||
reply,
|
reply,
|
||||||
...overrides,
|
...overrides,
|
||||||
} as unknown as StringSelectMenuInteraction;
|
} as unknown as StringSelectMenuInteraction;
|
||||||
return { interaction, reply };
|
return { interaction, defer, reply };
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -58,10 +62,11 @@ describe("agent components", () => {
|
|||||||
accountId: "default",
|
accountId: "default",
|
||||||
dmPolicy: "pairing",
|
dmPolicy: "pairing",
|
||||||
});
|
});
|
||||||
const { interaction, reply } = createDmButtonInteraction();
|
const { interaction, defer, reply } = createDmButtonInteraction();
|
||||||
|
|
||||||
await button.run(interaction, { componentId: "hello" } as ComponentData);
|
await button.run(interaction, { componentId: "hello" } as ComponentData);
|
||||||
|
|
||||||
|
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
|
||||||
expect(reply).toHaveBeenCalledTimes(1);
|
expect(reply).toHaveBeenCalledTimes(1);
|
||||||
expect(reply.mock.calls[0]?.[0]?.content).toContain("Pairing code: PAIRCODE");
|
expect(reply.mock.calls[0]?.[0]?.content).toContain("Pairing code: PAIRCODE");
|
||||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||||
@@ -74,11 +79,12 @@ describe("agent components", () => {
|
|||||||
accountId: "default",
|
accountId: "default",
|
||||||
dmPolicy: "allowlist",
|
dmPolicy: "allowlist",
|
||||||
});
|
});
|
||||||
const { interaction, reply } = createDmButtonInteraction();
|
const { interaction, defer, reply } = createDmButtonInteraction();
|
||||||
|
|
||||||
await button.run(interaction, { componentId: "hello" } as ComponentData);
|
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();
|
expect(enqueueSystemEventMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,11 +95,12 @@ describe("agent components", () => {
|
|||||||
dmPolicy: "allowlist",
|
dmPolicy: "allowlist",
|
||||||
allowFrom: ["Alice#1234"],
|
allowFrom: ["Alice#1234"],
|
||||||
});
|
});
|
||||||
const { interaction, reply } = createDmSelectInteraction();
|
const { interaction, defer, reply } = createDmSelectInteraction();
|
||||||
|
|
||||||
await select.run(interaction, { componentId: "hello" } as ComponentData);
|
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();
|
expect(enqueueSystemEventMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,15 +108,16 @@ async function ensureDmComponentAuthorized(params: {
|
|||||||
interaction: AgentComponentInteraction;
|
interaction: AgentComponentInteraction;
|
||||||
user: DiscordUser;
|
user: DiscordUser;
|
||||||
componentLabel: string;
|
componentLabel: string;
|
||||||
|
replyOpts: { ephemeral?: boolean };
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const { ctx, interaction, user, componentLabel } = params;
|
const { ctx, interaction, user, componentLabel, replyOpts } = params;
|
||||||
const dmPolicy = ctx.dmPolicy ?? "pairing";
|
const dmPolicy = ctx.dmPolicy ?? "pairing";
|
||||||
if (dmPolicy === "disabled") {
|
if (dmPolicy === "disabled") {
|
||||||
logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`);
|
logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`);
|
||||||
try {
|
try {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: "DM interactions are disabled.",
|
content: "DM interactions are disabled.",
|
||||||
ephemeral: true,
|
...replyOpts,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Interaction may have expired
|
// Interaction may have expired
|
||||||
@@ -162,7 +163,7 @@ async function ensureDmComponentAuthorized(params: {
|
|||||||
code,
|
code,
|
||||||
})
|
})
|
||||||
: "Pairing already requested. Ask the bot owner to approve your code.",
|
: "Pairing already requested. Ask the bot owner to approve your code.",
|
||||||
ephemeral: true,
|
...replyOpts,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Interaction may have expired
|
// Interaction may have expired
|
||||||
@@ -174,7 +175,7 @@ async function ensureDmComponentAuthorized(params: {
|
|||||||
try {
|
try {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: `You are not authorized to use this ${componentLabel}.`,
|
content: `You are not authorized to use this ${componentLabel}.`,
|
||||||
ephemeral: true,
|
...replyOpts,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Interaction may have expired
|
// Interaction may have expired
|
||||||
@@ -226,6 +227,18 @@ export class AgentComponentButton extends Button {
|
|||||||
return;
|
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 username = formatUsername(user);
|
||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
|
|
||||||
@@ -243,6 +256,7 @@ export class AgentComponentButton extends Button {
|
|||||||
interaction,
|
interaction,
|
||||||
user,
|
user,
|
||||||
componentLabel: "button",
|
componentLabel: "button",
|
||||||
|
replyOpts,
|
||||||
});
|
});
|
||||||
if (!authorized) {
|
if (!authorized) {
|
||||||
return;
|
return;
|
||||||
@@ -311,7 +325,7 @@ export class AgentComponentButton extends Button {
|
|||||||
try {
|
try {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: "You are not authorized to use this button.",
|
content: "You are not authorized to use this button.",
|
||||||
ephemeral: true,
|
...replyOpts,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Interaction may have expired
|
// Interaction may have expired
|
||||||
@@ -347,7 +361,7 @@ export class AgentComponentButton extends Button {
|
|||||||
try {
|
try {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: "✓",
|
content: "✓",
|
||||||
ephemeral: true,
|
...replyOpts,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(`agent button: failed to acknowledge interaction: ${String(err)}`);
|
logError(`agent button: failed to acknowledge interaction: ${String(err)}`);
|
||||||
@@ -397,6 +411,18 @@ export class AgentSelectMenu extends StringSelectMenu {
|
|||||||
return;
|
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 username = formatUsername(user);
|
||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
|
|
||||||
@@ -414,6 +440,7 @@ export class AgentSelectMenu extends StringSelectMenu {
|
|||||||
interaction,
|
interaction,
|
||||||
user,
|
user,
|
||||||
componentLabel: "select menu",
|
componentLabel: "select menu",
|
||||||
|
replyOpts,
|
||||||
});
|
});
|
||||||
if (!authorized) {
|
if (!authorized) {
|
||||||
return;
|
return;
|
||||||
@@ -478,7 +505,7 @@ export class AgentSelectMenu extends StringSelectMenu {
|
|||||||
try {
|
try {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: "You are not authorized to use this select menu.",
|
content: "You are not authorized to use this select menu.",
|
||||||
ephemeral: true,
|
...replyOpts,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Interaction may have expired
|
// Interaction may have expired
|
||||||
@@ -518,7 +545,7 @@ export class AgentSelectMenu extends StringSelectMenu {
|
|||||||
try {
|
try {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: "✓",
|
content: "✓",
|
||||||
ephemeral: true,
|
...replyOpts,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(`agent select: failed to acknowledge interaction: ${String(err)}`);
|
logError(`agent select: failed to acknowledge interaction: ${String(err)}`);
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ export class ChatLog extends Container {
|
|||||||
this.addChild(new AssistantMessageComponent(text));
|
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) {
|
startTool(toolCallId: string, toolName: string, args: unknown) {
|
||||||
const existing = this.toolById.get(toolCallId);
|
const existing = this.toolById.get(toolCallId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import { createEventHandlers } from "./tui-event-handlers.js";
|
|||||||
|
|
||||||
type MockChatLog = Pick<
|
type MockChatLog = Pick<
|
||||||
ChatLog,
|
ChatLog,
|
||||||
"startTool" | "updateToolResult" | "addSystem" | "updateAssistant" | "finalizeAssistant"
|
| "startTool"
|
||||||
|
| "updateToolResult"
|
||||||
|
| "addSystem"
|
||||||
|
| "updateAssistant"
|
||||||
|
| "finalizeAssistant"
|
||||||
|
| "dropAssistant"
|
||||||
>;
|
>;
|
||||||
type MockTui = Pick<TUI, "requestRender">;
|
type MockTui = Pick<TUI, "requestRender">;
|
||||||
|
|
||||||
@@ -41,6 +46,7 @@ describe("tui-event-handlers: handleAgentEvent", () => {
|
|||||||
addSystem: vi.fn(),
|
addSystem: vi.fn(),
|
||||||
updateAssistant: vi.fn(),
|
updateAssistant: vi.fn(),
|
||||||
finalizeAssistant: vi.fn(),
|
finalizeAssistant: vi.fn(),
|
||||||
|
dropAssistant: vi.fn(),
|
||||||
};
|
};
|
||||||
const tui: MockTui = { requestRender: vi.fn() };
|
const tui: MockTui = { requestRender: vi.fn() };
|
||||||
const setActivityStatus = vi.fn();
|
const setActivityStatus = vi.fn();
|
||||||
@@ -357,4 +363,33 @@ describe("tui-event-handlers: handleAgentEvent", () => {
|
|||||||
|
|
||||||
expect(loadHistory).toHaveBeenCalledTimes(1);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -109,6 +109,20 @@ export function createEventHandlers(context: EventHandlerContext) {
|
|||||||
setActivityStatus("streaming");
|
setActivityStatus("streaming");
|
||||||
}
|
}
|
||||||
if (evt.state === "final") {
|
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 (isCommandMessage(evt.message)) {
|
||||||
if (isLocalRunId?.(evt.runId)) {
|
if (isLocalRunId?.(evt.runId)) {
|
||||||
forgetLocalRunId?.(evt.runId);
|
forgetLocalRunId?.(evt.runId);
|
||||||
|
|||||||
Reference in New Issue
Block a user