import { beforeEach, describe, expect, it, vi } from "vitest"; import { createBaseDiscordMessageContext } from "./message-handler.test-harness.js"; const reactMessageDiscord = vi.fn(async () => {}); const removeReactionDiscord = vi.fn(async () => {}); type DispatchInboundParams = { replyOptions?: { onReasoningStream?: () => Promise | void; onToolStart?: (payload: { name?: string }) => Promise | void; }; }; const dispatchInboundMessage = vi.fn(async (_params?: DispatchInboundParams) => ({ queuedFinal: false, counts: { final: 0, tool: 0, block: 0 }, })); const recordInboundSession = vi.fn(async () => {}); const readSessionUpdatedAt = vi.fn(() => undefined); const resolveStorePath = vi.fn(() => "/tmp/openclaw-discord-process-test-sessions.json"); vi.mock("../send.js", () => ({ reactMessageDiscord, removeReactionDiscord, })); vi.mock("../../auto-reply/dispatch.js", () => ({ dispatchInboundMessage, })); vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({ createReplyDispatcherWithTyping: vi.fn(() => ({ dispatcher: { sendToolResult: vi.fn(() => true), sendBlockReply: vi.fn(() => true), sendFinalReply: vi.fn(() => true), waitForIdle: vi.fn(async () => {}), getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })), markComplete: vi.fn(), }, replyOptions: {}, markDispatchIdle: vi.fn(), })), })); vi.mock("../../channels/session.js", () => ({ recordInboundSession, })); vi.mock("../../config/sessions.js", () => ({ readSessionUpdatedAt, resolveStorePath, })); const { processDiscordMessage } = await import("./message-handler.process.js"); const createBaseContext = createBaseDiscordMessageContext; beforeEach(() => { vi.useRealTimers(); reactMessageDiscord.mockClear(); removeReactionDiscord.mockClear(); dispatchInboundMessage.mockReset(); recordInboundSession.mockReset(); readSessionUpdatedAt.mockReset(); resolveStorePath.mockReset(); dispatchInboundMessage.mockResolvedValue({ queuedFinal: false, counts: { final: 0, tool: 0, block: 0 }, }); recordInboundSession.mockResolvedValue(undefined); readSessionUpdatedAt.mockReturnValue(undefined); resolveStorePath.mockReturnValue("/tmp/openclaw-discord-process-test-sessions.json"); }); function getLastRouteUpdate(): | { sessionKey?: string; channel?: string; to?: string; accountId?: string } | undefined { const callArgs = recordInboundSession.mock.calls.at(-1) as unknown[] | undefined; const params = callArgs?.[0] as | { updateLastRoute?: { sessionKey?: string; channel?: string; to?: string; accountId?: string; }; } | undefined; return params?.updateLastRoute; } describe("processDiscordMessage ack reactions", () => { it("skips ack reactions for group-mentions when mentions are not required", async () => { const ctx = await createBaseContext({ shouldRequireMention: false, effectiveWasMentioned: false, }); // oxlint-disable-next-line typescript/no-explicit-any await processDiscordMessage(ctx as any); expect(reactMessageDiscord).not.toHaveBeenCalled(); }); it("sends ack reactions for mention-gated guild messages when mentioned", async () => { const ctx = await createBaseContext({ shouldRequireMention: true, effectiveWasMentioned: true, }); // oxlint-disable-next-line typescript/no-explicit-any await processDiscordMessage(ctx as any); expect(reactMessageDiscord.mock.calls[0]).toEqual(["c1", "m1", "👀", { rest: {} }]); }); it("uses preflight-resolved messageChannelId when message.channelId is missing", async () => { const ctx = await createBaseContext({ message: { id: "m1", timestamp: new Date().toISOString(), attachments: [], }, messageChannelId: "fallback-channel", shouldRequireMention: true, effectiveWasMentioned: true, }); // oxlint-disable-next-line typescript/no-explicit-any await processDiscordMessage(ctx as any); expect(reactMessageDiscord.mock.calls[0]).toEqual([ "fallback-channel", "m1", "👀", { rest: {} }, ]); }); it("debounces intermediate phase reactions and jumps to done for short runs", async () => { dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { await params?.replyOptions?.onReasoningStream?.(); await params?.replyOptions?.onToolStart?.({ name: "exec" }); return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; }); const ctx = await createBaseContext(); // oxlint-disable-next-line typescript/no-explicit-any await processDiscordMessage(ctx as any); const emojis = ( reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]> ).map((call) => call[2]); expect(emojis).toContain("👀"); expect(emojis).toContain("✅"); expect(emojis).not.toContain("🧠"); expect(emojis).not.toContain("💻"); }); it("shows stall emojis for long no-progress runs", async () => { vi.useFakeTimers(); dispatchInboundMessage.mockImplementationOnce(async () => { await new Promise((resolve) => { setTimeout(resolve, 31_000); }); return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; }); const ctx = await createBaseContext(); // oxlint-disable-next-line typescript/no-explicit-any const runPromise = processDiscordMessage(ctx as any); await vi.advanceTimersByTimeAsync(40_000); await runPromise; const emojis = ( reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]> ).map((call) => call[2]); expect(emojis).toContain("⏳"); expect(emojis).toContain("⚠️"); expect(emojis).toContain("✅"); }); }); describe("processDiscordMessage session routing", () => { it("stores DM lastRoute with user target for direct-session continuity", async () => { const ctx = await createBaseContext({ data: { guild: null }, channelInfo: null, channelName: undefined, isGuildMessage: false, isDirectMessage: true, isGroupDm: false, shouldRequireMention: false, canDetectMention: false, effectiveWasMentioned: false, displayChannelSlug: "", guildInfo: null, guildSlug: "", message: { id: "m1", channelId: "dm1", timestamp: new Date().toISOString(), attachments: [], }, messageChannelId: "dm1", baseSessionKey: "agent:main:discord:direct:u1", route: { agentId: "main", channel: "discord", accountId: "default", sessionKey: "agent:main:discord:direct:u1", mainSessionKey: "agent:main:main", }, }); // oxlint-disable-next-line typescript/no-explicit-any await processDiscordMessage(ctx as any); expect(getLastRouteUpdate()).toEqual({ sessionKey: "agent:main:discord:direct:u1", channel: "discord", to: "user:U1", accountId: "default", }); }); it("stores group lastRoute with channel target", async () => { const ctx = await createBaseContext({ baseSessionKey: "agent:main:discord:channel:c1", route: { agentId: "main", channel: "discord", accountId: "default", sessionKey: "agent:main:discord:channel:c1", mainSessionKey: "agent:main:main", }, }); // oxlint-disable-next-line typescript/no-explicit-any await processDiscordMessage(ctx as any); expect(getLastRouteUpdate()).toEqual({ sessionKey: "agent:main:discord:channel:c1", channel: "discord", to: "channel:c1", accountId: "default", }); }); });