fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models (#94067)

Merged via squash.

Prepared head SHA: 079347b8b8
Co-authored-by: openperf <80630709+openperf@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
This commit is contained in:
Chunyue Wang
2026-06-21 11:56:53 +08:00
committed by GitHub
parent d4c2fa7aed
commit e3ccf8743f
12 changed files with 243 additions and 15 deletions

View File

@@ -7,7 +7,8 @@ import {
} from "openclaw/plugin-sdk/runtime-config-snapshot";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const { logVerboseMock } = vi.hoisted(() => ({
const { loadModelCatalogMock, logVerboseMock } = vi.hoisted(() => ({
loadModelCatalogMock: vi.fn(),
logVerboseMock: vi.fn(),
}));
const { loggerWarnMock } = vi.hoisted(() => ({
@@ -32,6 +33,7 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
});
vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
loadModelCatalog: loadModelCatalogMock,
resolveHumanDelayConfig: () => undefined,
}));
@@ -227,6 +229,7 @@ describe("createDiscordNativeCommand option wiring", () => {
beforeEach(() => {
clearRuntimeConfigSnapshot();
loadModelCatalogMock.mockReset().mockResolvedValue([]);
logVerboseMock.mockReset();
loggerWarnMock.mockReset();
});
@@ -257,6 +260,30 @@ describe("createDiscordNativeCommand option wiring", () => {
]);
});
it("uses the provider-startup catalog snapshot for /think autocomplete", async () => {
const cfg = {
channels: {
discord: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
},
},
} as OpenClawConfig;
const command = createNativeCommand("think", { cfg });
const level = requireOption(command, "level");
const autocomplete = requireAutocomplete(level, "think level option did not wire autocomplete");
await runAutocomplete(autocomplete, {
userId: "owner",
channelType: ChannelType.DM,
channelId: "dm-1",
channelName: "dm-1",
focusedValue: "",
});
expect(loadModelCatalogMock).toHaveBeenCalledWith({ cacheOnly: true });
expect(loadModelCatalogMock).toHaveBeenCalledWith({ config: cfg });
});
it("keeps static choices for non-acp string action arguments", () => {
const command = createNativeCommand("config");
const action = requireOption(command, "action");

View File

@@ -1,5 +1,6 @@
// Discord plugin module implements native command.options behavior.
import { ApplicationCommandOptionType } from "discord-api-types/v10";
import { loadModelCatalog } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
resolveCommandArgChoices,
@@ -117,12 +118,17 @@ export function buildDiscordCommandOptions(params: {
? await resolveChoiceContext(interaction)
: null;
const currentCfg = resolveConfig?.() ?? cfg;
// Autocomplete cannot defer beyond Discord's three-second deadline.
// Cache-only catalog reads never start discovery or filesystem work.
const choiceCatalog =
command.key === "think" ? await loadModelCatalog({ cacheOnly: true }) : undefined;
const choices = resolveCommandArgChoices({
command,
arg,
cfg: currentCfg,
provider: context?.provider,
model: context?.model,
...(choiceCatalog?.length ? { catalog: choiceCatalog } : {}),
});
const filtered = focusValue
? choices.filter((choice) =>
@@ -132,6 +138,11 @@ export function buildDiscordCommandOptions(params: {
await interaction.respond(
filtered.slice(0, 25).map((choice) => ({ name: choice.label, value: choice.value })),
);
if (command.key === "think" && !choiceCatalog?.length) {
// The interaction is acknowledged now, so a failed startup warmup can retry
// discovery without risking Discord's response deadline.
void loadModelCatalog({ config: currentCfg });
}
}
: undefined;
const choices =

View File

@@ -1,5 +1,6 @@
// Discord plugin module implements native command behavior.
import { ApplicationCommandOptionType } from "discord-api-types/v10";
import { loadModelCatalog } from "openclaw/plugin-sdk/agent-runtime";
import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
@@ -485,12 +486,18 @@ async function dispatchDiscordCommandInteraction(params: {
threadBindings,
})
: null;
// Native /think choices need live-discovery metadata; empty keeps config fallback.
const menuModelCatalog =
command.key === "think" && menuNeedsModelContext
? await loadModelCatalog({ config: cfg })
: undefined;
const menu = resolveCommandArgMenu({
command,
args: commandArgs,
cfg,
provider: menuModelContext?.provider,
model: menuModelContext?.model,
...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}),
});
if (menu) {
const menuPayload = buildDiscordCommandArgMenu({

View File

@@ -1,3 +1,4 @@
import { loadModelCatalog } from "openclaw/plugin-sdk/agent-runtime";
// Discord provider module implements model/runtime integration.
import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract";
import {
@@ -395,6 +396,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
let earlyGatewayEmitter = gatewaySupervisor?.emitter;
let onEarlyGatewayDebug: ((msg: unknown) => void) | undefined;
try {
if (nativeEnabled && commandSpecs.some((command) => command.name === "think")) {
// Autocomplete cannot defer. Warm opportunistically before interactions begin,
// but never let provider discovery block Discord startup.
void loadModelCatalog({ config: cfg });
}
const { commands, components, modals } = createDiscordProviderInteractionSurface({
cfg,
discordConfig: discordCfg,

View File

@@ -1,6 +1,6 @@
// Slack plugin module implements slash behavior.
import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt";
import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime";
import { loadModelCatalog, resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime";
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
import {
formatCommandArgMenuTitle,
@@ -596,11 +596,17 @@ export async function registerSlackMonitorSlashCommands(params: {
sessionKey: menuRoute.sessionKey,
})
: {};
// Native /think choices need live-discovery metadata; empty keeps config fallback.
const menuModelCatalog =
commandDefinition.key === "think" && menuNeedsModelContext
? await loadModelCatalog({ config: cfg })
: undefined;
const menu = resolveCommandArgMenu({
command: commandDefinition,
args: commandArgs,
cfg,
...menuModelContext,
...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}),
});
if (menu) {
const commandLabel = commandDefinition.nativeName ?? commandDefinition.key;

View File

@@ -657,6 +657,68 @@ describe("registerTelegramNativeCommands — session metadata", () => {
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("resolves /think menu choices against the runtime catalog for live-discovered models", async () => {
const cfg = {
agents: { defaults: { models: { "ollama/*": {} } } },
} as OpenClawConfig;
sessionMocks.loadSessionStore.mockReturnValue({
"agent:main:main": {
providerOverride: "ollama",
modelOverride: "glm-5.2:cloud",
modelOverrideSource: "user",
updatedAt: 0,
},
});
const runtimeCatalog = [
{ provider: "ollama", id: "glm-5.2:cloud", name: "glm-5.2:cloud", reasoning: true },
];
agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue(runtimeCatalog);
const { handler } = registerAndResolveCommandHandler({
commandName: "think",
cfg,
allowFrom: ["*"],
});
await handler(createTelegramPrivateCommandContext());
const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find(
([params]) => params.command.key === "think" && params.provider === "ollama",
)?.[0];
const menuRecord = expectRecordFields(
menuCall,
{ provider: "ollama", model: "glm-5.2:cloud" },
"ollama thinking menu call",
);
expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalled();
expect(menuRecord.catalog).toEqual(runtimeCatalog);
});
it("loads the runtime catalog for /think when no session model override is set", async () => {
const cfg = {
agents: { defaults: { model: "ollama/glm-5.2:cloud", models: { "ollama/*": {} } } },
} as OpenClawConfig;
sessionMocks.loadSessionStore.mockReturnValue({});
const runtimeCatalog = [
{ provider: "ollama", id: "glm-5.2:cloud", name: "glm-5.2:cloud", reasoning: true },
];
agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue(runtimeCatalog);
const { handler } = registerAndResolveCommandHandler({
commandName: "think",
cfg,
allowFrom: ["*"],
});
await handler(createTelegramPrivateCommandContext());
expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalled();
const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find(
([params]) => params.command.key === "think",
)?.[0];
const menuRecord = expectRecordFields(menuCall, {}, "default-model thinking menu call");
expect(menuRecord.provider).toBeUndefined();
expect(menuRecord.catalog).toEqual(runtimeCatalog);
});
it("inherits the parent session model when building DM thread native argument menus", async () => {
const cfg: OpenClawConfig = {};
sessionMocks.loadSessionStore.mockReturnValue({
@@ -855,6 +917,7 @@ describe("registerTelegramNativeCommands — session metadata", () => {
await handler(createTelegramPrivateCommandContext({ match: "high" }));
expect(sessionMocks.loadSessionStore).not.toHaveBeenCalled();
expect(agentRuntimeMocks.loadModelCatalog).not.toHaveBeenCalled();
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
});

View File

@@ -1135,12 +1135,18 @@ export const registerTelegramNativeCommands = ({
sessionKey: await resolveTargetSessionKey(),
})
: {};
// Native /think choices need live-discovery metadata; empty keeps config fallback.
const menuModelCatalog =
commandDefinition?.key === "think" && menuNeedsModelContext
? await loadModelCatalog({ config: runtimeCfg })
: undefined;
const menu = commandDefinition
? resolveCommandArgMenu({
command: commandDefinition,
args: commandArgs,
cfg: runtimeCfg,
...menuModelContext,
...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}),
})
: null;
if (menu && commandDefinition) {

View File

@@ -13,6 +13,7 @@ let findModelInCatalog: typeof import("./model-catalog.js").findModelInCatalog;
let loadManifestModelCatalog: typeof import("./model-catalog.js").loadManifestModelCatalog;
let loadModelCatalog: typeof import("./model-catalog.js").loadModelCatalog;
let modelSupportsInput: typeof import("./model-catalog.js").modelSupportsInput;
let resetModelCatalogCache: typeof import("./model-catalog.js").resetModelCatalogCache;
let resetModelCatalogCacheForTest: typeof import("./model-catalog.js").resetModelCatalogCacheForTest;
let augmentCatalogMock: ReturnType<typeof vi.fn>;
let prepareOpenClawModelsJsonSourceMock: ReturnType<typeof vi.fn>;
@@ -337,6 +338,7 @@ describe("loadModelCatalog", () => {
loadManifestModelCatalog,
loadModelCatalog,
modelSupportsInput,
resetModelCatalogCache,
resetModelCatalogCacheForTest,
} = await import("./model-catalog.js"));
const providerRuntime = await import("../plugins/provider-runtime.runtime.js");
@@ -512,6 +514,57 @@ describe("loadModelCatalog", () => {
});
});
it("exposes only a fully loaded process catalog snapshot", async () => {
mockAgentDiscoveryModels([
{ id: "runtime-reasoner", name: "Runtime Reasoner", provider: "ollama", reasoning: true },
]);
await expect(loadModelCatalog({ cacheOnly: true })).resolves.toEqual([]);
const result = await loadModelCatalog({ config: {} as OpenClawConfig });
await expect(loadModelCatalog({ cacheOnly: true })).resolves.toBe(result);
resetModelCatalogCache();
await expect(loadModelCatalog({ cacheOnly: true })).resolves.toEqual([]);
resetModelCatalogCacheForTest();
await expect(loadModelCatalog({ cacheOnly: true })).resolves.toEqual([]);
});
it("does not publish a catalog load from an invalidated generation", async () => {
let releaseStaleFingerprint:
| ((value: { agentDir: string; fingerprint: string; workspaceDir: string }) => void)
| undefined;
const staleFingerprint = new Promise<{
agentDir: string;
fingerprint: string;
workspaceDir: string;
}>((resolve) => {
releaseStaleFingerprint = resolve;
});
buildModelsJsonSourceFingerprintMock.mockReturnValueOnce(staleFingerprint).mockResolvedValue({
agentDir: "/tmp/openclaw",
fingerprint: "fresh-fingerprint",
workspaceDir: "/tmp/openclaw-workspace",
});
const freshCatalog = [{ id: "fresh", name: "Fresh", provider: "ollama", reasoning: true }];
const staleCatalog = [{ id: "stale", name: "Stale", provider: "ollama", reasoning: false }];
readCachedAgentModelCatalogMock
.mockReturnValueOnce(freshCatalog)
.mockReturnValueOnce(staleCatalog);
const staleLoad = loadModelCatalog({ config: {} as OpenClawConfig });
resetModelCatalogCache();
await expect(loadModelCatalog({ config: {} as OpenClawConfig })).resolves.toBe(freshCatalog);
await expect(loadModelCatalog({ cacheOnly: true })).resolves.toBe(freshCatalog);
releaseStaleFingerprint?.({
agentDir: "/tmp/openclaw",
fingerprint: "stale-fingerprint",
workspaceDir: "/tmp/openclaw-workspace",
});
await expect(staleLoad).resolves.toBe(staleCatalog);
await expect(loadModelCatalog({ cacheOnly: true })).resolves.toBe(freshCatalog);
});
it("preserves runtime model params in the internal catalog", async () => {
mockAgentDiscoveryModels([
{
@@ -727,6 +780,7 @@ describe("loadModelCatalog", () => {
const result = await loadModelCatalog({ config: {} as OpenClawConfig });
expect(result).toEqual([{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]);
await expect(loadModelCatalog({ cacheOnly: true })).resolves.toEqual([]);
} finally {
setLoggerOverride(null);
resetLogger();

View File

@@ -78,6 +78,9 @@ type DiscoveredModel = {
type AgentDiscoveryModule = typeof import("./agent-model-discovery.js");
let modelCatalogPromise: Promise<ModelCatalogEntry[]> | null = null;
let loadedModelCatalogSnapshot: ModelCatalogEntry[] | undefined;
let loadedModelCatalogGeneration = -1;
let modelCatalogGeneration = 0;
let hasLoggedModelCatalogError = false;
let hasLoggedReadOnlyStaticCatalogError = false;
type ManifestModelCatalogCacheEntry = {
@@ -127,6 +130,7 @@ function loadProviderApiKeyResolver() {
export function resetModelCatalogCache() {
modelCatalogPromise = null;
modelCatalogGeneration += 1;
manifestModelCatalogCache = new WeakMap();
hasLoggedModelCatalogError = false;
hasLoggedReadOnlyStaticCatalogError = false;
@@ -134,6 +138,8 @@ export function resetModelCatalogCache() {
export function resetModelCatalogCacheForTest() {
resetModelCatalogCache();
loadedModelCatalogSnapshot = undefined;
loadedModelCatalogGeneration = -1;
importAgentDiscovery = defaultImportAgentDiscovery;
}
@@ -542,9 +548,15 @@ function loadReadOnlyStaticModelCatalog(params?: {
export async function loadModelCatalog(params?: {
config?: OpenClawConfig;
useCache?: boolean;
cacheOnly?: boolean;
readOnly?: boolean;
metadataSnapshot?: PluginMetadataSnapshot;
}): Promise<ModelCatalogEntry[]> {
if (params?.cacheOnly === true) {
return loadedModelCatalogGeneration === modelCatalogGeneration
? (loadedModelCatalogSnapshot ?? [])
: [];
}
const readOnly = params?.readOnly === true;
if (readOnly) {
try {
@@ -557,6 +569,7 @@ export async function loadModelCatalog(params?: {
}
if (!readOnly && params?.useCache === false) {
modelCatalogPromise = null;
modelCatalogGeneration += 1;
}
const useSharedCache = !readOnly && !params?.metadataSnapshot;
if (useSharedCache && modelCatalogPromise) {
@@ -811,8 +824,20 @@ export async function loadModelCatalog(params?: {
return loadCatalog();
}
modelCatalogPromise = loadCatalog();
return modelCatalogPromise;
const loadGeneration = modelCatalogGeneration;
const publishedPromise = loadCatalog().then((catalog) => {
if (
catalog.length > 0 &&
modelCatalogGeneration === loadGeneration &&
modelCatalogPromise === publishedPromise
) {
loadedModelCatalogSnapshot = catalog;
loadedModelCatalogGeneration = loadGeneration;
}
return catalog;
});
modelCatalogPromise = publishedPromise;
return publishedPromise;
}
/**

View File

@@ -740,24 +740,37 @@ describe("commands registry args", () => {
expect(seenChoice.catalogLength).toBe(0);
});
it("uses configured model catalog reasoning for /think arg menus", () => {
installOllamaThinkingProvider();
const command = requireNativeCommand("think");
const menu = requireCommandArgMenu({
command,
args: undefined,
it.each([
{
source: "configured",
cfg: {
models: {
providers: {
ollama: {
models: [{ id: "glm-5.1:cloud", name: "GLM 5.1 Cloud", reasoning: true }],
models: [{ id: "glm-5.2:cloud", name: "GLM 5.2 Cloud", reasoning: true }],
},
},
},
} as never,
},
catalog: undefined,
},
{
source: "runtime",
cfg: { agents: { defaults: { models: { "ollama/*": {} } } } },
catalog: [
{ provider: "ollama", id: "glm-5.2:cloud", name: "GLM 5.2 Cloud", reasoning: true },
],
},
])("uses $source model catalog reasoning for /think arg menus", ({ cfg, catalog }) => {
installOllamaThinkingProvider();
const command = requireNativeCommand("think");
const menu = requireCommandArgMenu({
command,
args: undefined,
cfg: cfg as never,
provider: "ollama",
model: "glm-5.1:cloud",
model: "glm-5.2:cloud",
catalog,
});
expect(menu.arg.name).toBe("level");

View File

@@ -57,6 +57,7 @@ const hoisted = vi.hoisted(() => ({
markRestartAbortedMainSessions: vi.fn(async (_params: unknown) => ({ marked: 1, skipped: 0 })),
runtimeConfig: { value: { session: { store: "/tmp/active-sessions.json" } } as OpenClawConfig },
reloadEvents: [] as string[],
loadModelCatalog: vi.fn(async (_params: { config: OpenClawConfig }) => []),
resetModelCatalogCache: vi.fn(() => {}),
refreshContextWindowCache: vi.fn(async (_cfg: OpenClawConfig) => {}),
clearCurrentProviderAuthState: vi.fn(() => {}),
@@ -118,6 +119,10 @@ vi.mock("../config/config.js", () => ({
}));
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: (params: { config: OpenClawConfig }) => {
hoisted.reloadEvents.push("load-model-catalog");
return hoisted.loadModelCatalog(params);
},
resetModelCatalogCache: () => {
hoisted.reloadEvents.push("reset-model-catalog");
hoisted.resetModelCatalogCache();
@@ -198,6 +203,7 @@ afterEach(() => {
hoisted.markRestartAbortedMainSessions.mockClear();
hoisted.runtimeConfig.value = { session: { store: "/tmp/active-sessions.json" } };
hoisted.reloadEvents.length = 0;
hoisted.loadModelCatalog.mockClear();
hoisted.resetModelCatalogCache.mockClear();
hoisted.refreshContextWindowCache.mockClear();
hoisted.clearCurrentProviderAuthState.mockClear();
@@ -271,9 +277,11 @@ describe("gateway hot reload model state", () => {
"reset-model-catalog",
"clear-provider-auth",
"refresh-context-window",
"load-model-catalog",
"warm-provider-auth",
]);
expect(hoisted.refreshContextWindowCache).toHaveBeenCalledWith(nextConfig);
expect(hoisted.loadModelCatalog).toHaveBeenCalledWith({ config: nextConfig });
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledWith(nextConfig);
});

View File

@@ -7,7 +7,7 @@ import {
listActiveEmbeddedRunSessionIds,
listActiveEmbeddedRunSessionKeys,
} from "../agents/embedded-agent-runner/run-state.js";
import { resetModelCatalogCache } from "../agents/model-catalog.js";
import { loadModelCatalog, resetModelCatalogCache } from "../agents/model-catalog.js";
import {
clearCurrentProviderAuthState,
warmCurrentProviderAuthStateOffMainThread,
@@ -524,6 +524,8 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams)
if (shouldRefreshContextWindowCache(plan)) {
await refreshContextWindowCache(nextConfig);
// Provider discovery is best-effort; a slow hook must not hold hot reload open.
void loadModelCatalog({ config: nextConfig });
}
void warmCurrentProviderAuthStateOffMainThread(nextConfig).catch((err: unknown) => {
params.logReload.warn(`provider auth state rewarm failed: ${String(err)}`);