import { ChannelType } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as commandRegistryModule from "../../auto-reply/commands-registry.js"; import type { ChatCommandDefinition, CommandArgsParsing, } from "../../auto-reply/commands-registry.types.js"; import type { ModelsProviderData } from "../../auto-reply/reply/commands-models.js"; import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js"; import type { OpenClawConfig } from "../../config/config.js"; import * as timeoutModule from "../../utils/with-timeout.js"; import * as modelPickerPreferencesModule from "./model-picker-preferences.js"; import * as modelPickerModule from "./model-picker.js"; import { createDiscordModelPickerFallbackButton, createDiscordModelPickerFallbackSelect, } from "./native-command.js"; function createModelsProviderData(entries: Record): ModelsProviderData { const byProvider = new Map>(); for (const [provider, models] of Object.entries(entries)) { byProvider.set(provider, new Set(models)); } const providers = Object.keys(entries).toSorted(); return { byProvider, providers, resolvedDefault: { provider: providers[0] ?? "openai", model: entries[providers[0] ?? "openai"]?.[0] ?? "gpt-4o", }, }; } type ModelPickerContext = Parameters[0]; type PickerButton = ReturnType; type PickerSelect = ReturnType; type PickerButtonInteraction = Parameters[0]; type PickerButtonData = Parameters[1]; type PickerSelectInteraction = Parameters[0]; type PickerSelectData = Parameters[1]; type MockInteraction = { user: { id: string; username: string; globalName: string }; channel: { type: ChannelType; id: string }; guild: null; rawData: { id: string; member: { roles: string[] } }; values?: string[]; reply: ReturnType; followUp: ReturnType; update: ReturnType; acknowledge: ReturnType; client: object; }; function createModelPickerContext(): ModelPickerContext { const cfg = { channels: { discord: { dm: { enabled: true, policy: "open", }, }, }, } as unknown as OpenClawConfig; return { cfg, discordConfig: cfg.channels?.discord ?? {}, accountId: "default", sessionPrefix: "discord:slash", }; } function createInteraction(params?: { userId?: string; values?: string[] }): MockInteraction { const userId = params?.userId ?? "owner"; return { user: { id: userId, username: "tester", globalName: "Tester", }, channel: { type: ChannelType.DM, id: "dm-1", }, guild: null, rawData: { id: "interaction-1", member: { roles: [] }, }, values: params?.values, reply: vi.fn().mockResolvedValue({ ok: true }), followUp: vi.fn().mockResolvedValue({ ok: true }), update: vi.fn().mockResolvedValue({ ok: true }), acknowledge: vi.fn().mockResolvedValue({ ok: true }), client: {}, }; } describe("Discord model picker interactions", () => { beforeEach(() => { vi.restoreAllMocks(); }); it("registers distinct fallback ids for button and select handlers", () => { const context = createModelPickerContext(); const button = createDiscordModelPickerFallbackButton(context); const select = createDiscordModelPickerFallbackSelect(context); expect(button.customId).not.toBe(select.customId); expect(button.customId.split(":")[0]).toBe(select.customId.split(":")[0]); }); it("ignores interactions from users other than the picker owner", async () => { const context = createModelPickerContext(); const loadSpy = vi.spyOn(modelPickerModule, "loadDiscordModelPickerData"); const button = createDiscordModelPickerFallbackButton(context); const interaction = createInteraction({ userId: "intruder" }); const data: PickerButtonData = { cmd: "model", act: "back", view: "providers", u: "owner", pg: "1", }; await button.run(interaction as unknown as PickerButtonInteraction, data); expect(interaction.acknowledge).toHaveBeenCalledTimes(1); expect(interaction.update).not.toHaveBeenCalled(); expect(loadSpy).not.toHaveBeenCalled(); }); it("requires submit click before routing selected model through /model pipeline", async () => { const context = createModelPickerContext(); const pickerData = createModelsProviderData({ openai: ["gpt-4.1", "gpt-4o"], anthropic: ["claude-sonnet-4-5"], }); const modelCommand: ChatCommandDefinition = { key: "model", nativeName: "model", description: "Switch model", textAliases: ["/model"], acceptsArgs: true, argsParsing: "none" as CommandArgsParsing, scope: "native", }; vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData); vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockImplementation((name) => name === "model" ? modelCommand : undefined, ); vi.spyOn(commandRegistryModule, "listChatCommands").mockReturnValue([modelCommand]); vi.spyOn(commandRegistryModule, "resolveCommandArgMenu").mockReturnValue(null); const dispatchSpy = vi .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") .mockResolvedValue({} as never); const select = createDiscordModelPickerFallbackSelect(context); const selectInteraction = createInteraction({ userId: "owner", values: ["gpt-4o"], }); const selectData: PickerSelectData = { cmd: "model", act: "model", view: "models", u: "owner", p: "openai", pg: "1", }; await select.run(selectInteraction as unknown as PickerSelectInteraction, selectData); expect(selectInteraction.update).toHaveBeenCalledTimes(1); expect(dispatchSpy).not.toHaveBeenCalled(); const button = createDiscordModelPickerFallbackButton(context); const submitInteraction = createInteraction({ userId: "owner" }); const submitData: PickerButtonData = { cmd: "model", act: "submit", view: "models", u: "owner", p: "openai", pg: "1", mi: "2", }; await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData); expect(submitInteraction.update).toHaveBeenCalledTimes(1); expect(dispatchSpy).toHaveBeenCalledTimes(1); const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as { ctx?: { CommandBody?: string; CommandArgs?: { values?: { model?: string } }; CommandTargetSessionKey?: string; }; }; expect(dispatchCall.ctx?.CommandBody).toBe("/model openai/gpt-4o"); expect(dispatchCall.ctx?.CommandArgs?.values?.model).toBe("openai/gpt-4o"); expect(dispatchCall.ctx?.CommandTargetSessionKey).toBeDefined(); }); it("shows timeout status and skips recents write when apply is still processing", async () => { const context = createModelPickerContext(); const pickerData = createModelsProviderData({ openai: ["gpt-4.1", "gpt-4o"], anthropic: ["claude-sonnet-4-5"], }); const modelCommand: ChatCommandDefinition = { key: "model", nativeName: "model", description: "Switch model", textAliases: ["/model"], acceptsArgs: true, argsParsing: "none" as CommandArgsParsing, scope: "native", }; vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData); vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockImplementation((name) => name === "model" ? modelCommand : undefined, ); vi.spyOn(commandRegistryModule, "listChatCommands").mockReturnValue([modelCommand]); vi.spyOn(commandRegistryModule, "resolveCommandArgMenu").mockReturnValue(null); const recordRecentSpy = vi .spyOn(modelPickerPreferencesModule, "recordDiscordModelPickerRecentModel") .mockResolvedValue(); const dispatchSpy = vi .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") .mockImplementation(() => new Promise(() => {}) as never); const withTimeoutSpy = vi .spyOn(timeoutModule, "withTimeout") .mockRejectedValue(new Error("timeout")); const select = createDiscordModelPickerFallbackSelect(context); const selectInteraction = createInteraction({ userId: "owner", values: ["gpt-4o"], }); const selectData: PickerSelectData = { cmd: "model", act: "model", view: "models", u: "owner", p: "openai", pg: "1", }; await select.run(selectInteraction as unknown as PickerSelectInteraction, selectData); const button = createDiscordModelPickerFallbackButton(context); const submitInteraction = createInteraction({ userId: "owner" }); const submitData: PickerButtonData = { cmd: "model", act: "submit", view: "models", u: "owner", p: "openai", pg: "1", mi: "2", }; await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData); expect(withTimeoutSpy).toHaveBeenCalledTimes(1); expect(dispatchSpy).toHaveBeenCalledTimes(1); expect(submitInteraction.followUp).toHaveBeenCalledTimes(1); const followUpPayload = submitInteraction.followUp.mock.calls[0]?.[0] as { components?: Array<{ components?: Array<{ content?: string }> }>; }; const followUpText = JSON.stringify(followUpPayload); expect(followUpText).toContain("still processing"); expect(recordRecentSpy).not.toHaveBeenCalled(); }); it("clicking Recents button renders recents view", async () => { const context = createModelPickerContext(); const pickerData = createModelsProviderData({ openai: ["gpt-4.1", "gpt-4o"], anthropic: ["claude-sonnet-4-5"], }); vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData); vi.spyOn(modelPickerPreferencesModule, "readDiscordModelPickerRecentModels").mockResolvedValue([ "openai/gpt-4o", "anthropic/claude-sonnet-4-5", ]); const button = createDiscordModelPickerFallbackButton(context); const interaction = createInteraction({ userId: "owner" }); const data: PickerButtonData = { cmd: "model", act: "recents", view: "recents", u: "owner", p: "openai", pg: "1", }; await button.run(interaction as unknown as PickerButtonInteraction, data); expect(interaction.update).toHaveBeenCalledTimes(1); const updatePayload = interaction.update.mock.calls[0]?.[0]; expect(updatePayload).toBeDefined(); expect(updatePayload.components).toBeDefined(); }); it("clicking recents model button applies model through /model pipeline", async () => { const context = createModelPickerContext(); const pickerData = createModelsProviderData({ openai: ["gpt-4.1", "gpt-4o"], anthropic: ["claude-sonnet-4-5"], }); const modelCommand: ChatCommandDefinition = { key: "model", nativeName: "model", description: "Switch model", textAliases: ["/model"], acceptsArgs: true, argsParsing: "none" as CommandArgsParsing, scope: "native", }; vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData); vi.spyOn(modelPickerPreferencesModule, "readDiscordModelPickerRecentModels").mockResolvedValue([ "openai/gpt-4o", "anthropic/claude-sonnet-4-5", ]); vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockImplementation((name) => name === "model" ? modelCommand : (undefined as never), ); vi.spyOn(commandRegistryModule, "listChatCommands").mockReturnValue([modelCommand]); vi.spyOn(commandRegistryModule, "resolveCommandArgMenu").mockReturnValue(null); const dispatchSpy = vi .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") .mockResolvedValue({} as never); const button = createDiscordModelPickerFallbackButton(context); const submitInteraction = createInteraction({ userId: "owner" }); // rs=2 → first deduped recent (default is anthropic/claude-sonnet-4-5, so openai/gpt-4o remains) const submitData: PickerButtonData = { cmd: "model", act: "submit", view: "recents", u: "owner", pg: "1", rs: "2", }; await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData); expect(submitInteraction.update).toHaveBeenCalledTimes(1); expect(dispatchSpy).toHaveBeenCalledTimes(1); const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as { ctx?: { CommandBody?: string; CommandArgs?: { values?: { model?: string } }; }; }; expect(dispatchCall.ctx?.CommandBody).toBe("/model openai/gpt-4o"); expect(dispatchCall.ctx?.CommandArgs?.values?.model).toBe("openai/gpt-4o"); }); });