From e3ccf8743f559f83ce72fbe97e606bb761847d3d Mon Sep 17 00:00:00 2001 From: Chunyue Wang <80630709+openperf@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:56:53 +0800 Subject: [PATCH] fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models (#94067) Merged via squash. Prepared head SHA: 079347b8b86ae1e0f05dc3b409d5aed863c7317d Co-authored-by: openperf <80630709+openperf@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Reviewed-by: @steipete --- .../monitor/native-command.options.test.ts | 29 ++++++++- .../src/monitor/native-command.options.ts | 11 ++++ .../discord/src/monitor/native-command.ts | 7 +++ extensions/discord/src/monitor/provider.ts | 6 ++ extensions/slack/src/monitor/slash.ts | 8 ++- .../bot-native-commands.session-meta.test.ts | 63 +++++++++++++++++++ .../telegram/src/bot-native-commands.ts | 6 ++ src/agents/model-catalog.test.ts | 54 ++++++++++++++++ src/agents/model-catalog.ts | 29 ++++++++- src/auto-reply/commands-registry.test.ts | 33 +++++++--- src/gateway/server-reload-handlers.test.ts | 8 +++ src/gateway/server-reload-handlers.ts | 4 +- 12 files changed, 243 insertions(+), 15 deletions(-) diff --git a/extensions/discord/src/monitor/native-command.options.test.ts b/extensions/discord/src/monitor/native-command.options.test.ts index 0b8b602f995b..f7e9b5c544da 100644 --- a/extensions/discord/src/monitor/native-command.options.test.ts +++ b/extensions/discord/src/monitor/native-command.options.test.ts @@ -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"); diff --git a/extensions/discord/src/monitor/native-command.options.ts b/extensions/discord/src/monitor/native-command.options.ts index 069e04a13448..1d0a639bfe5f 100644 --- a/extensions/discord/src/monitor/native-command.options.ts +++ b/extensions/discord/src/monitor/native-command.options.ts @@ -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 = diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 265ad224edba..15a579942489 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -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({ diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index d9d3d8e61244..504bceab23bb 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -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, diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index c09895cf0e95..5b48f2b1aef0 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -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; diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index 8cb0fddfc761..58b27bc21945 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -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); }); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index b18cf15ae5ab..2d95251102ef 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -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) { diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index 121e4bce4cca..90b4ba2607ee 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -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; let prepareOpenClawModelsJsonSourceMock: ReturnType; @@ -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(); diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index 9f77a8ecfe25..a45ba35bc4aa 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -78,6 +78,9 @@ type DiscoveredModel = { type AgentDiscoveryModule = typeof import("./agent-model-discovery.js"); let modelCatalogPromise: Promise | 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 { + 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; } /** diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index f9ab1a2d88f0..7cc324577e79 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -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"); diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index c92b0eb50005..786fdf5fceb5 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -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); }); diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index a06a28c8ce1b..e3ed454bafcb 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -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)}`);