openclaw/extensions/discord/src/monitor/model-picker.test.ts
scoootscooob 5682ec37fa
refactor: move Discord channel implementation to extensions/ (#45660)
* refactor: move Discord channel implementation to extensions/discord/src/

Move all Discord source files from src/discord/ to extensions/discord/src/,
following the extension migration pattern. Source files in src/discord/ are
replaced with re-export shims. Channel-plugin files from
src/channels/plugins/*/discord* are similarly moved and shimmed.

- Copy all .ts source files preserving subdirectory structure (monitor/, voice/)
- Move channel-plugin files (actions, normalize, onboarding, outbound, status-issues)
- Fix all relative imports to use correct paths from new location
- Create re-export shims at original locations for backward compatibility
- Delete test files from shim locations (tests live in extension now)
- Update tsconfig.plugin-sdk.dts.json rootDir from "src" to "." to accommodate
  extension files outside src/
- Update write-plugin-sdk-entry-dts.ts to match new declaration output paths

* fix: add importOriginal to thread-bindings session-meta mock for extensions test

* style: fix formatting in thread-bindings lifecycle test
2026-03-14 02:53:57 -07:00

659 lines
21 KiB
TypeScript

import { serializePayload } from "@buape/carbon";
import { ComponentType } from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest";
import * as modelsCommandModule from "../../../../src/auto-reply/reply/commands-models.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import {
DISCORD_CUSTOM_ID_MAX_CHARS,
DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE,
DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE,
DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX,
buildDiscordModelPickerCustomId,
getDiscordModelPickerModelPage,
getDiscordModelPickerProviderPage,
loadDiscordModelPickerData,
parseDiscordModelPickerCustomId,
parseDiscordModelPickerData,
renderDiscordModelPickerModelsView,
renderDiscordModelPickerProvidersView,
renderDiscordModelPickerRecentsView,
toDiscordModelPickerMessagePayload,
} from "./model-picker.js";
import { createModelsProviderData } from "./model-picker.test-utils.js";
type SerializedComponent = {
type: number;
custom_id?: string;
options?: Array<{ value: string; default?: boolean }>;
components?: SerializedComponent[];
};
function extractContainerRows(components?: SerializedComponent[]): SerializedComponent[] {
const container = components?.find(
(component) => component.type === Number(ComponentType.Container),
);
if (!container) {
return [];
}
return (container.components ?? []).filter(
(component) => component.type === Number(ComponentType.ActionRow),
);
}
function renderModelsViewRows(
params: Parameters<typeof renderDiscordModelPickerModelsView>[0],
): SerializedComponent[] {
const rendered = renderDiscordModelPickerModelsView(params);
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
components?: SerializedComponent[];
};
return extractContainerRows(payload.components);
}
function renderRecentsViewRows(
params: Parameters<typeof renderDiscordModelPickerRecentsView>[0],
): SerializedComponent[] {
const rendered = renderDiscordModelPickerRecentsView(params);
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
components?: SerializedComponent[];
};
return extractContainerRows(payload.components);
}
describe("loadDiscordModelPickerData", () => {
it("reuses buildModelsProviderData as source of truth with agent scope", async () => {
const expected = createModelsProviderData({ openai: ["gpt-4o"] });
const cfg = {} as OpenClawConfig;
const spy = vi
.spyOn(modelsCommandModule, "buildModelsProviderData")
.mockResolvedValue(expected);
const result = await loadDiscordModelPickerData(cfg, "support");
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(cfg, "support");
expect(result).toBe(expected);
});
});
describe("Discord model picker custom_id", () => {
it("encodes and decodes command/provider/page/user context", () => {
const customId = buildDiscordModelPickerCustomId({
command: "models",
action: "provider",
view: "models",
provider: "OpenAI",
page: 3,
userId: "1234567890",
});
const parsed = parseDiscordModelPickerCustomId(customId);
expect(parsed).toEqual({
command: "models",
action: "provider",
view: "models",
provider: "openai",
page: 3,
userId: "1234567890",
});
});
it("parses component data payloads", () => {
const parsed = parseDiscordModelPickerData({
cmd: "model",
act: "back",
view: "providers",
u: "42",
p: "anthropic",
pg: "2",
});
expect(parsed).toEqual({
command: "model",
action: "back",
view: "providers",
userId: "42",
provider: "anthropic",
page: 2,
});
});
it("parses compact custom_id aliases", () => {
const parsed = parseDiscordModelPickerData({
c: "models",
a: "submit",
v: "models",
u: "42",
p: "openai",
g: "3",
mi: "2",
});
expect(parsed).toEqual({
command: "models",
action: "submit",
view: "models",
userId: "42",
provider: "openai",
page: 3,
modelIndex: 2,
});
});
it("parses optional submit model index", () => {
const parsed = parseDiscordModelPickerData({
cmd: "models",
act: "submit",
view: "models",
u: "42",
p: "openai",
pg: "1",
mi: "7",
});
expect(parsed).toEqual({
command: "models",
action: "submit",
view: "models",
userId: "42",
provider: "openai",
page: 1,
modelIndex: 7,
});
});
it("rejects invalid command/action/view values", () => {
expect(
parseDiscordModelPickerData({
cmd: "status",
act: "nav",
view: "providers",
u: "42",
}),
).toBeNull();
expect(
parseDiscordModelPickerData({
cmd: "model",
act: "unknown",
view: "providers",
u: "42",
}),
).toBeNull();
expect(
parseDiscordModelPickerData({
cmd: "model",
act: "nav",
view: "unknown",
u: "42",
}),
).toBeNull();
});
it("enforces Discord custom_id max length", () => {
const longProvider = `provider-${"x".repeat(DISCORD_CUSTOM_ID_MAX_CHARS)}`;
expect(() =>
buildDiscordModelPickerCustomId({
command: "model",
action: "provider",
view: "models",
provider: longProvider,
page: 1,
userId: "42",
}),
).toThrow(/custom_id exceeds/i);
});
it("keeps typical submit ids under Discord max length", () => {
const customId = buildDiscordModelPickerCustomId({
command: "models",
action: "submit",
view: "models",
provider: "azure-openai-responses",
page: 1,
providerPage: 1,
modelIndex: 10,
userId: "12345678901234567890",
});
expect(customId.length).toBeLessThanOrEqual(DISCORD_CUSTOM_ID_MAX_CHARS);
});
});
describe("provider paging", () => {
it("keeps providers on a single page when count fits Discord button rows", () => {
const entries: Record<string, string[]> = {};
for (let i = 1; i <= DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX - 2; i += 1) {
entries[`provider-${String(i).padStart(2, "0")}`] = [`model-${i}`];
}
const data = createModelsProviderData(entries);
const page = getDiscordModelPickerProviderPage({ data, page: 1 });
expect(page.items).toHaveLength(DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX - 2);
expect(page.totalPages).toBe(1);
expect(page.pageSize).toBe(DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX);
expect(page.hasPrev).toBe(false);
expect(page.hasNext).toBe(false);
});
it("paginates providers when count exceeds one-page Discord button limits", () => {
const entries: Record<string, string[]> = {};
for (let i = 1; i <= DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX + 3; i += 1) {
entries[`provider-${String(i).padStart(2, "0")}`] = [`model-${i}`];
}
const data = createModelsProviderData(entries);
const page1 = getDiscordModelPickerProviderPage({ data, page: 1 });
const lastPage = getDiscordModelPickerProviderPage({ data, page: 99 });
expect(page1.items).toHaveLength(DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE);
expect(page1.totalPages).toBe(2);
expect(page1.hasNext).toBe(true);
expect(lastPage.page).toBe(2);
expect(lastPage.items).toHaveLength(8);
expect(lastPage.hasPrev).toBe(true);
expect(lastPage.hasNext).toBe(false);
});
it("caps custom provider page size at Discord-safe max", () => {
const compactData = createModelsProviderData({
anthropic: ["claude-sonnet-4-5"],
openai: ["gpt-4o"],
google: ["gemini-3-pro"],
});
const compactPage = getDiscordModelPickerProviderPage({
data: compactData,
page: 1,
pageSize: 999,
});
expect(compactPage.pageSize).toBe(DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX);
const pagedEntries: Record<string, string[]> = {};
for (let i = 1; i <= DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX + 1; i += 1) {
pagedEntries[`provider-${String(i).padStart(2, "0")}`] = [`model-${i}`];
}
const pagedData = createModelsProviderData(pagedEntries);
const pagedPage = getDiscordModelPickerProviderPage({
data: pagedData,
page: 1,
pageSize: 999,
});
expect(pagedPage.pageSize).toBe(DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE);
});
});
describe("model paging", () => {
it("sorts models and paginates with Discord select-option constraints", () => {
const models = Array.from(
{ length: DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE + 4 },
(_, idx) =>
`model-${String(DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE + 4 - idx).padStart(2, "0")}`,
);
const data = createModelsProviderData({ openai: models });
const page1 = getDiscordModelPickerModelPage({ data, provider: "openai", page: 1 });
const page2 = getDiscordModelPickerModelPage({ data, provider: "openai", page: 2 });
expect(page1).not.toBeNull();
expect(page2).not.toBeNull();
expect(page1?.items).toHaveLength(DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE);
expect(page1?.items[0]).toBe("model-01");
expect(page1?.hasNext).toBe(true);
expect(page2?.items).toHaveLength(4);
expect(page2?.page).toBe(2);
expect(page2?.hasPrev).toBe(true);
expect(page2?.hasNext).toBe(false);
});
it("returns null for unknown provider", () => {
const data = createModelsProviderData({ anthropic: ["claude-sonnet-4-5"] });
const page = getDiscordModelPickerModelPage({ data, provider: "openai", page: 1 });
expect(page).toBeNull();
});
it("caps custom model page size at Discord select-option max", () => {
const data = createModelsProviderData({ openai: ["gpt-4o", "gpt-4.1"] });
const page = getDiscordModelPickerModelPage({ data, provider: "openai", pageSize: 999 });
expect(page?.pageSize).toBe(DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE);
});
});
describe("Discord model picker rendering", () => {
it("renders provider view on one page when provider count is <= 25", () => {
const entries: Record<string, string[]> = {};
for (let i = 1; i <= 22; i += 1) {
entries[`provider-${String(i).padStart(2, "0")}`] = [`model-${i}`];
}
entries["azure-openai-responses"] = ["gpt-4.1"];
entries["vercel-ai-gateway"] = ["gpt-4o-mini"];
const data = createModelsProviderData(entries);
const rendered = renderDiscordModelPickerProvidersView({
command: "models",
userId: "42",
data,
currentModel: "provider-01/model-1",
});
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
content?: string;
components?: SerializedComponent[];
};
expect(payload.content).toBeUndefined();
expect(payload.components?.[0]?.type).toBe(ComponentType.Container);
const rows = extractContainerRows(payload.components);
expect(rows.length).toBeGreaterThan(0);
const rowProviderCounts = rows.map(
(row) =>
(row.components ?? []).filter((component) => {
const parsed = parseDiscordModelPickerCustomId(component.custom_id ?? "");
return parsed?.action === "provider";
}).length,
);
expect(rowProviderCounts).toEqual([4, 5, 5, 5, 5]);
const allButtons = rows.flatMap((row) => row.components ?? []);
const providerButtons = allButtons.filter((component) => {
const parsed = parseDiscordModelPickerCustomId(component.custom_id ?? "");
return parsed?.action === "provider";
});
expect(providerButtons).toHaveLength(Object.keys(entries).length);
expect(allButtons.some((component) => (component.custom_id ?? "").includes(";a=nav;"))).toBe(
false,
);
});
it("does not render navigation buttons even when provider count exceeds one page", () => {
const entries: Record<string, string[]> = {};
for (let i = 1; i <= DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX + 4; i += 1) {
entries[`provider-${String(i).padStart(2, "0")}`] = [`model-${i}`];
}
const data = createModelsProviderData(entries);
const rendered = renderDiscordModelPickerProvidersView({
command: "models",
userId: "42",
data,
currentModel: "provider-01/model-1",
});
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
components?: SerializedComponent[];
};
const rows = extractContainerRows(payload.components);
expect(rows.length).toBeGreaterThan(0);
const allButtons = rows.flatMap((row) => row.components ?? []);
expect(allButtons.some((component) => (component.custom_id ?? "").includes(";a=nav;"))).toBe(
false,
);
});
it("supports classic fallback rendering with content + action rows", () => {
const data = createModelsProviderData({ openai: ["gpt-4o"], anthropic: ["claude-sonnet-4-5"] });
const rendered = renderDiscordModelPickerProvidersView({
command: "model",
userId: "99",
data,
layout: "classic",
});
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
content?: string;
components?: SerializedComponent[];
};
expect(payload.content).toContain("Model Picker");
expect(payload.components?.[0]?.type).toBe(ComponentType.ActionRow);
});
it("preserves the stored model suffix spacing in Discord current-model text", () => {
const data = createModelsProviderData({ openai: [" gpt-5", "gpt-4o"] });
const rendered = renderDiscordModelPickerProvidersView({
command: "model",
userId: "99",
data,
currentModel: " OpenAI/ gpt-5 ",
layout: "classic",
});
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
content?: string;
};
expect(payload.content).toContain("Current model: openai/ gpt-5");
});
it("renders model view with select menu and explicit submit button", () => {
const data = createModelsProviderData({
openai: ["gpt-4.1", "gpt-4o", "o3"],
anthropic: ["claude-sonnet-4-5"],
});
const rendered = renderDiscordModelPickerModelsView({
command: "models",
userId: "42",
data,
provider: "openai",
page: 1,
providerPage: 2,
currentModel: "openai/gpt-4o",
pendingModel: "openai/o3",
pendingModelIndex: 3,
});
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
components?: SerializedComponent[];
};
const rows = extractContainerRows(payload.components);
expect(rows).toHaveLength(3);
const providerSelect = rows[0]?.components?.find(
(component) => component.type === Number(ComponentType.StringSelect),
);
expect(providerSelect).toBeTruthy();
expect(providerSelect?.options?.length).toBe(2);
expect(providerSelect?.options?.find((option) => option.value === "openai")?.default).toBe(
true,
);
const parsedProviderState = parseDiscordModelPickerCustomId(providerSelect?.custom_id ?? "");
expect(parsedProviderState?.action).toBe("provider");
const modelSelect = rows[1]?.components?.find(
(component) => component.type === Number(ComponentType.StringSelect),
);
expect(modelSelect).toBeTruthy();
expect(modelSelect?.options?.length).toBe(3);
expect(modelSelect?.options?.find((option) => option.value === "o3")?.default).toBe(true);
const parsedModelSelectState = parseDiscordModelPickerCustomId(modelSelect?.custom_id ?? "");
expect(parsedModelSelectState?.action).toBe("model");
expect(parsedModelSelectState?.provider).toBe("openai");
const navButtons = rows[2]?.components ?? [];
expect(navButtons).toHaveLength(3);
const cancelState = parseDiscordModelPickerCustomId(navButtons[0]?.custom_id ?? "");
expect(cancelState?.action).toBe("cancel");
const resetState = parseDiscordModelPickerCustomId(navButtons[1]?.custom_id ?? "");
expect(resetState?.action).toBe("reset");
expect(resetState?.provider).toBe("openai");
const submitState = parseDiscordModelPickerCustomId(navButtons[2]?.custom_id ?? "");
expect(submitState?.action).toBe("submit");
expect(submitState?.provider).toBe("openai");
expect(submitState?.modelIndex).toBe(3);
});
it("renders not-found model view with a back button", () => {
const data = createModelsProviderData({ openai: ["gpt-4o"] });
const rendered = renderDiscordModelPickerModelsView({
command: "model",
userId: "42",
data,
provider: "does-not-exist",
providerPage: 3,
});
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
components?: SerializedComponent[];
};
const rows = extractContainerRows(payload.components);
expect(rows).toHaveLength(1);
const backButton = rows[0]?.components?.[0];
expect(backButton?.type).toBe(ComponentType.Button);
const state = parseDiscordModelPickerCustomId(backButton?.custom_id ?? "");
expect(state?.action).toBe("back");
expect(state?.view).toBe("providers");
expect(state?.page).toBe(3);
});
it("shows Recents button when quickModels are provided", () => {
const data = createModelsProviderData({
openai: ["gpt-4.1", "gpt-4o"],
anthropic: ["claude-sonnet-4-5"],
});
const rows = renderModelsViewRows({
command: "model",
userId: "42",
data,
provider: "openai",
page: 1,
providerPage: 1,
currentModel: "openai/gpt-4o",
quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"],
});
const buttonRow = rows[2];
const buttons = buttonRow?.components ?? [];
expect(buttons).toHaveLength(4);
const favoritesState = parseDiscordModelPickerCustomId(buttons[2]?.custom_id ?? "");
expect(favoritesState?.action).toBe("recents");
expect(favoritesState?.view).toBe("recents");
});
it("omits Recents button when no quickModels", () => {
const data = createModelsProviderData({
openai: ["gpt-4.1", "gpt-4o"],
});
const rows = renderModelsViewRows({
command: "model",
userId: "42",
data,
provider: "openai",
page: 1,
providerPage: 1,
currentModel: "openai/gpt-4o",
});
const buttonRow = rows[2];
const buttons = buttonRow?.components ?? [];
expect(buttons).toHaveLength(3);
const allActions = buttons.map(
(b) => parseDiscordModelPickerCustomId(b?.custom_id ?? "")?.action,
);
expect(allActions).not.toContain("recents");
});
});
describe("Discord model picker recents view", () => {
it("renders one button per model with back button after divider", () => {
const data = createModelsProviderData({
openai: ["gpt-4.1", "gpt-4o"],
anthropic: ["claude-sonnet-4-5"],
});
// Default is openai/gpt-4.1 (first key in entries).
// Neither quickModel matches, so no deduping — 1 default + 2 recents + 1 back = 4 rows.
const rows = renderRecentsViewRows({
command: "model",
userId: "42",
data,
quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"],
currentModel: "openai/gpt-4o",
});
expect(rows).toHaveLength(4);
// First row: default model button (slot 1).
const defaultBtn = rows[0]?.components?.[0];
expect(defaultBtn?.type).toBe(ComponentType.Button);
const defaultState = parseDiscordModelPickerCustomId(defaultBtn?.custom_id ?? "");
expect(defaultState?.action).toBe("submit");
expect(defaultState?.view).toBe("recents");
expect(defaultState?.recentSlot).toBe(1);
// Second row: first recent (slot 2).
const recentBtn1 = rows[1]?.components?.[0];
const recentState1 = parseDiscordModelPickerCustomId(recentBtn1?.custom_id ?? "");
expect(recentState1?.recentSlot).toBe(2);
// Third row: second recent (slot 3).
const recentBtn2 = rows[2]?.components?.[0];
const recentState2 = parseDiscordModelPickerCustomId(recentBtn2?.custom_id ?? "");
expect(recentState2?.recentSlot).toBe(3);
// Fourth row (after divider): Back button.
const backBtn = rows[3]?.components?.[0];
const backState = parseDiscordModelPickerCustomId(backBtn?.custom_id ?? "");
expect(backState?.action).toBe("back");
expect(backState?.view).toBe("models");
});
it("includes (default) suffix on default model button label", () => {
const data = createModelsProviderData({
openai: ["gpt-4o"],
});
const rows = renderRecentsViewRows({
command: "model",
userId: "42",
data,
quickModels: ["openai/gpt-4o"],
currentModel: "openai/gpt-4o",
});
const defaultBtn = rows[0]?.components?.[0] as { label?: string };
expect(defaultBtn?.label).toContain("(default)");
});
it("deduplicates recents that match the default model", () => {
const data = createModelsProviderData({
openai: ["gpt-4o"],
anthropic: ["claude-sonnet-4-5"],
});
// Default is openai/gpt-4o (first key). quickModels contains the default.
const rows = renderRecentsViewRows({
command: "model",
userId: "42",
data,
quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"],
currentModel: "openai/gpt-4o",
});
// 1 default + 1 deduped recent + 1 back = 3 rows (openai/gpt-4o not shown twice)
expect(rows).toHaveLength(3);
const defaultBtn = rows[0]?.components?.[0] as { label?: string };
expect(defaultBtn?.label).toContain("openai/gpt-4o");
expect(defaultBtn?.label).toContain("(default)");
const recentBtn = rows[1]?.components?.[0] as { label?: string };
expect(recentBtn?.label).toContain("anthropic/claude-sonnet-4-5");
});
});