Mattermost: add interactive model picker (#38767)

Merged via squash.

Prepared head SHA: 0883654e88
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
This commit is contained in:
Muhammed Mukhthar CM
2026-03-07 21:45:29 +05:30
committed by GitHub
parent 33e7394861
commit 4f08dcccfd
23 changed files with 1867 additions and 290 deletions

View File

@@ -1,12 +1,11 @@
import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
import { resolveModelAuthLabel } from "../../agents/model-auth-label.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import {
buildAllowedModelSet,
buildModelAliasIndex,
normalizeProviderId,
resolveConfiguredModelRef,
resolveDefaultModelForAgent,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -35,11 +34,13 @@ export type ModelsProviderData = {
* Build provider/model data from config and catalog.
* Exported for reuse by callback handlers.
*/
export async function buildModelsProviderData(cfg: OpenClawConfig): Promise<ModelsProviderData> {
const resolvedDefault = resolveConfiguredModelRef({
export async function buildModelsProviderData(
cfg: OpenClawConfig,
agentId?: string,
): Promise<ModelsProviderData> {
const resolvedDefault = resolveDefaultModelForAgent({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
agentId,
});
const catalog = await loadModelCatalog({ config: cfg });
@@ -220,6 +221,7 @@ export async function resolveModelsCommandReply(params: {
commandBodyNormalized: string;
surface?: string;
currentModel?: string;
agentId?: string;
agentDir?: string;
sessionEntry?: SessionEntry;
}): Promise<ReplyPayload | null> {
@@ -231,7 +233,7 @@ export async function resolveModelsCommandReply(params: {
const argText = body.replace(/^\/models\b/i, "").trim();
const { provider, page, pageSize, all } = parseModelsArgs(argText);
const { byProvider, providers } = await buildModelsProviderData(params.cfg);
const { byProvider, providers } = await buildModelsProviderData(params.cfg, params.agentId);
const isTelegram = params.surface === "telegram";
// Provider list (no provider specified)
@@ -386,6 +388,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
commandBodyNormalized,
surface: params.ctx.Surface,
currentModel: params.model ? `${params.provider}/${params.model}` : undefined,
agentId: modelsAgentId,
agentDir: modelsAgentDir,
sessionEntry: params.sessionEntry,
});

View File

@@ -907,6 +907,28 @@ describe("/models command", () => {
expect(result.reply?.text).toContain("localai/ultra-chat");
expect(result.reply?.text).not.toContain("Unknown provider");
});
it("threads the routed agent through /models replies", async () => {
const scopedCfg = {
commands: { text: true },
agents: {
defaults: { model: { primary: "anthropic/claude-opus-4-5" } },
list: [{ id: "support", model: "localai/ultra-chat" }],
},
} as unknown as OpenClawConfig;
const params = buildPolicyParams("/models", scopedCfg, {
Provider: "discord",
Surface: "discord",
});
const result = await handleCommands({
...params,
agentId: "support",
sessionKey: "agent:support:main",
});
expect(result.reply?.text).toContain("localai");
});
});
describe("handleCommands plugin commands", () => {

View File

@@ -61,15 +61,17 @@ function renderRecentsViewRows(
}
describe("loadDiscordModelPickerData", () => {
it("reuses buildModelsProviderData as source of truth", async () => {
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({} as OpenClawConfig);
const result = await loadDiscordModelPickerData(cfg, "support");
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(cfg, "support");
expect(result).toBe(expected);
});
});

View File

@@ -541,8 +541,11 @@ function buildModelRows(params: {
* Source-of-truth data for Discord picker views. This intentionally reuses the
* same provider/model resolver used by text and Telegram model commands.
*/
export async function loadDiscordModelPickerData(cfg: OpenClawConfig): Promise<ModelsProviderData> {
return buildModelsProviderData(cfg);
export async function loadDiscordModelPickerData(
cfg: OpenClawConfig,
agentId?: string,
): Promise<ModelsProviderData> {
return buildModelsProviderData(cfg, agentId);
}
export function buildDiscordModelPickerCustomId(params: {

View File

@@ -476,13 +476,13 @@ async function replyWithDiscordModelPickerProviders(params: {
threadBindings: ThreadBindingManager;
preferFollowUp: boolean;
}) {
const data = await loadDiscordModelPickerData(params.cfg);
const route = await resolveDiscordModelPickerRoute({
interaction: params.interaction,
cfg: params.cfg,
accountId: params.accountId,
threadBindings: params.threadBindings,
});
const data = await loadDiscordModelPickerData(params.cfg, route.agentId);
const currentModel = resolveDiscordModelPickerCurrentModel({
cfg: params.cfg,
route,
@@ -637,13 +637,13 @@ async function handleDiscordModelPickerInteraction(
return;
}
const pickerData = await loadDiscordModelPickerData(ctx.cfg);
const route = await resolveDiscordModelPickerRoute({
interaction,
cfg: ctx.cfg,
accountId: ctx.accountId,
threadBindings: ctx.threadBindings,
});
const pickerData = await loadDiscordModelPickerData(ctx.cfg, route.agentId);
const currentModelRef = resolveDiscordModelPickerCurrentModel({
cfg: ctx.cfg,
route,

View File

@@ -15,6 +15,12 @@ export type { ChatType } from "../channels/chat-type.js";
export { resolveControlCommandGate } from "../channels/command-gating.js";
export { logInboundDrop, logTypingFailure } from "../channels/logging.js";
export { resolveAllowlistMatchSimple } from "../channels/plugins/allowlist-match.js";
export { normalizeProviderId } from "../agents/model-selection.js";
export {
buildModelsProviderData,
type ModelsProviderData,
} from "../auto-reply/reply/commands-models.js";
export { resolveStoredModelOverride } from "../auto-reply/reply/model-selection.js";
export {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
@@ -44,6 +50,7 @@ export { createReplyPrefixOptions } from "../channels/reply-prefix.js";
export { createTypingCallbacks } from "../channels/typing.js";
export type { OpenClawConfig } from "../config/config.js";
export { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js";
export { loadSessionStore, resolveStorePath } from "../config/sessions.js";
export {
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
@@ -65,6 +72,7 @@ export {
} from "../config/zod-schema.core.js";
export { createDedupeCache } from "../infra/dedupe.js";
export { rawDataToString } from "../infra/ws.js";
export { isLoopbackHost, isTrustedProxyAddress, resolveClientIp } from "../gateway/net.js";
export { registerPluginHttpRoute } from "../plugins/http-registry.js";
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export type { PluginRuntime } from "../plugins/runtime/types.js";

View File

@@ -1179,7 +1179,15 @@ export const registerTelegramHandlers = ({
// Model selection callback handler (mdl_prov, mdl_list_*, mdl_sel_*, mdl_back)
const modelCallback = parseModelCallbackData(data);
if (modelCallback) {
const modelData = await buildModelsProviderData(cfg);
const sessionState = resolveTelegramSessionState({
chatId,
isGroup,
isForum,
messageThreadId,
resolvedThreadId,
senderId,
});
const modelData = await buildModelsProviderData(cfg, sessionState.agentId);
const { byProvider, providers } = modelData;
const editMessageWithButtons = async (
@@ -1238,14 +1246,15 @@ export const registerTelegramHandlers = ({
const safePage = Math.max(1, Math.min(page, totalPages));
// Resolve current model from session (prefer overrides)
const sessionState = resolveTelegramSessionState({
const currentSessionState = resolveTelegramSessionState({
chatId,
isGroup,
isForum,
messageThreadId,
resolvedThreadId,
senderId,
});
const currentModel = sessionState.model;
const currentModel = currentSessionState.model;
const buttons = buildModelsKeyboard({
provider,
@@ -1259,8 +1268,8 @@ export const registerTelegramHandlers = ({
provider,
total: models.length,
cfg,
agentDir: resolveAgentDir(cfg, sessionState.agentId),
sessionEntry: sessionState.sessionEntry,
agentDir: resolveAgentDir(cfg, currentSessionState.agentId),
sessionEntry: currentSessionState.sessionEntry,
});
await editMessageWithButtons(text, buttons);
return;