diff --git a/src/agents/model-catalog-browse.ts b/src/agents/model-catalog-browse.ts index 1c15c2be24e5..1656b6b73303 100644 --- a/src/agents/model-catalog-browse.ts +++ b/src/agents/model-catalog-browse.ts @@ -91,16 +91,17 @@ async function loadCatalogForBrowse(params: { agentId: params.agentId, view, }); - // Provider-policy wildcards newly escalate ordinary inventory views to live discovery. - // Keep those implicit loads within the browse deadline; explicit all/configured loads retain - // their existing completion semantics unless the caller requests a timeout. + // Implicit inventory reads stay bounded; explicit refreshes complete unless their caller + // explicitly requests a full-discovery deadline. const shouldTimeoutFullDiscovery = - params.timeoutFullDiscovery || - (requiresFullDiscovery && (view === "default" || view === "provider-config")); - if (requiresFullDiscovery && !shouldTimeoutFullDiscovery) { + params.timeoutFullDiscovery === true || + (params.refresh !== true && + requiresFullDiscovery && + (view === "default" || view === "provider-config")); + if ((requiresFullDiscovery || params.refresh === true) && !shouldTimeoutFullDiscovery) { return await params.loadCatalog({ - readOnly: false, - ...(params.refresh ? { refresh: true } : {}), + readOnly: !requiresFullDiscovery, + ...(requiresFullDiscovery && params.refresh ? { refresh: true } : {}), }); } diff --git a/src/gateway/server-methods/models-list-result.configured-static.test.ts b/src/gateway/server-methods/models-list-result.configured-static.test.ts index c74aaca7f965..8d859cf22152 100644 --- a/src/gateway/server-methods/models-list-result.configured-static.test.ts +++ b/src/gateway/server-methods/models-list-result.configured-static.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { withEnvAsync } from "../../test-utils/env.js"; import { @@ -9,6 +9,80 @@ import { } from "./models-list-result.openai-routes.test-support.js"; describe("models.list configured static entries", () => { + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it("waits for the complete configured catalog when explicit refresh exceeds the browse deadline", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const catalog = [ + { ...catalogEntry("gpt-5.6-luna", "openai-responses"), name: "Refreshed Luna" }, + { ...catalogEntry("gpt-5.6-sol", "openai-responses"), name: "Refreshed Sol" }, + ]; + const config = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.6-luna" }, + models: { "openai/gpt-5.6-luna": {}, "openai/gpt-5.6-sol": {} }, + }, + }, + } as OpenClawConfig; + + const result = listModels({ + catalog, + catalogLoadDelayMs: 800, + publishedCatalog: catalog.slice(0, 1), + cfg: config, + refresh: true, + view: "configured", + }); + + let settled = false; + void result.then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(750); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(50); + + expect((await result).models.map(({ id, name }) => ({ id, name }))).toEqual([ + { id: "gpt-5.6-luna", name: "Refreshed Luna" }, + { id: "gpt-5.6-sol", name: "Refreshed Sol" }, + ]); + }); + + it("keeps the published configured catalog when an implicit load exceeds the browse deadline", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const publishedCatalog = [ + { ...catalogEntry("gpt-5.6-luna", "openai-responses"), name: "Published Luna" }, + { ...catalogEntry("gpt-5.6-sol", "openai-responses"), name: "Published Sol" }, + ]; + const config = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.6-luna" }, + models: { "openai/gpt-5.6-luna": {}, "openai/gpt-5.6-sol": {} }, + }, + }, + } as OpenClawConfig; + + const result = listModels({ + catalog: [], + catalogLoadDelayMs: 800, + publishedCatalog, + cfg: config, + view: "configured", + }); + + await vi.advanceTimersByTimeAsync(750); + + expect((await result).models.map(({ id, name }) => ({ id, name }))).toEqual([ + { id: "gpt-5.6-luna", name: "Published Luna" }, + { id: "gpt-5.6-sol", name: "Published Sol" }, + ]); + }); + it("projects a configured runtime model from prepared static facts", async () => { const config = { agents: { diff --git a/src/gateway/server-methods/models-list-result.openai-routes.test-support.ts b/src/gateway/server-methods/models-list-result.openai-routes.test-support.ts index 3afc10c7411b..3cd54d5a4c76 100644 --- a/src/gateway/server-methods/models-list-result.openai-routes.test-support.ts +++ b/src/gateway/server-methods/models-list-result.openai-routes.test-support.ts @@ -45,6 +45,9 @@ export function registerTestCatalogAccess( export async function listModels(params: { agentId?: string; catalog: ModelCatalogEntry[]; + catalogLoadDelayMs?: number; + publishedCatalog?: ModelCatalogEntry[]; + refresh?: boolean; staticEntries?: ModelCatalogEntry[]; cfg?: OpenClawConfig; discoveryModes?: Record; @@ -55,7 +58,7 @@ export async function listModels(params: { }) { const agentId = params.agentId ?? "main"; const config = params.cfg ?? ({} as OpenClawConfig); - const loadGatewayModelCatalogSnapshot = async () => + const createCatalogSnapshot = (entries: ModelCatalogEntry[]) => ({ agentId, agentDir: "/tmp/models-list-openai-agent", @@ -68,24 +71,34 @@ export async function listModels(params: { }), metadataSnapshot: params.metadataSnapshot ?? loadManifestMetadataSnapshot({ config, env: process.env }), - entries: params.catalog, - routeVariants: params.catalog, + entries, + routeVariants: entries, ...(params.staticEntries ? { staticEntries: params.staticEntries } : {}), authMaterializations: [], }) satisfies PreparedGatewayModelCatalogSnapshot; + const loadGatewayModelCatalogSnapshot = async () => { + if (params.catalogLoadDelayMs !== undefined) { + await new Promise((resolve) => { + setTimeout(resolve, params.catalogLoadDelayMs); + }); + } + return createCatalogSnapshot(params.catalog); + }; registerGatewayModelCatalogPrivateAccess(loadGatewayModelCatalogSnapshot, { loadDeferred: loadGatewayModelCatalogSnapshot, - readPrepared: loadGatewayModelCatalogSnapshot, + readPrepared: params.publishedCatalog + ? async () => createCatalogSnapshot(params.publishedCatalog ?? []) + : loadGatewayModelCatalogSnapshot, }); const context = { getRuntimeConfig: () => config, loadGatewayModelCatalogSnapshot, - logGateway: { debug: () => {} }, + logGateway: { debug: () => {}, warn: () => {} }, } as unknown as GatewayRequestContext; return await buildModelsListResult({ context, agentId, - params: { view: params.view ?? "all" }, + params: { view: params.view ?? "all", ...(params.refresh ? { refresh: true } : {}) }, ...(params.discoveryModes ? { preloadedCatalog: { diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index e0d717932b85..dda0043cd6da 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -464,13 +464,15 @@ export async function buildModelsListResult( let loadedSnapshot: Awaited> | undefined; let loadedReadOnly = true; let usedPreloadedCatalog = false; + let catalogTimedOut = false; const handleCatalogTimeout = (timeoutMs: number) => { + catalogTimedOut = true; if (loggedSlowModelsListCatalog) { return; } loggedSlowModelsListCatalog = true; - params.context.logGateway.debug( - `models.list continuing without model catalog after ${timeoutMs}ms`, + params.context.logGateway.warn( + `models.list catalog load exceeded ${timeoutMs}ms; using the prepared catalog when available`, ); }; let snapshot = await loadPreparedModelCatalogSnapshotForBrowse({ @@ -555,6 +557,9 @@ export async function buildModelsListResult( (preloadedCatalog && params.catalogProjector ? undefined : await readPreparedCatalog(params.context, initialAgentId)); + if (catalogTimedOut && ownerSnapshot) { + snapshot = ownerSnapshot; + } const cfg = ownerSnapshot?.config ?? initialConfig; const agentId = ownerSnapshot?.agentId ?? initialAgentId; const workspaceDir = diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index fb9d8708e579..7501bf4e8c3f 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -357,6 +357,7 @@ function requestModelsList(params: { loadGatewayModelCatalogSnapshot, logGateway: { debug: vi.fn(), + warn: vi.fn(), }, } as never, }); diff --git a/ui/src/e2e/chat-flow.model-picker-refresh.e2e.test.ts b/ui/src/e2e/chat-flow.model-picker-refresh.e2e.test.ts new file mode 100644 index 000000000000..c5e85f379f08 --- /dev/null +++ b/ui/src/e2e/chat-flow.model-picker-refresh.e2e.test.ts @@ -0,0 +1,79 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import type { Page } from "playwright"; +import { expect, it } from "vitest"; +import { + createChatFlowE2eSuite, + installMockGateway, + requireRecord, +} from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); + +const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; +const proofDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "model-picker-refresh"); + +async function screenshot(page: Page, name: string) { + if (!captureUiProof) { + return; + } + await mkdir(proofDir, { recursive: true }); + await page.screenshot({ animations: "disabled", path: path.join(proofDir, name) }); +} + +suite.define(() => { + it("keeps the warm model list interactive while a picker-open refresh is in flight", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + models: [ + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", provider: "openai" }, + { id: "gpt-5.6-luna", name: "GPT-5.6 Luna", provider: "openai" }, + { id: "fable-5", name: "Claude Fable 5", provider: "anthropic" }, + ], + sessionKey: "main", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const pane = page.locator('openclaw-chat-pane[aria-hidden="false"]'); + const picker = pane.locator(".chat-controls__model-picker"); + await picker.locator("[data-chat-model-option]").first().waitFor({ state: "attached" }); + + // Freeze the operator-signaled revalidation so the in-flight state is observable. + await gateway.deferNext("models.list", { refresh: true }); + await picker.locator('[data-chat-model-select="true"]').click(); + const request = await gateway.waitForRequest("models.list"); + expect(requireRecord(request.params)).toMatchObject({ refresh: true, view: "configured" }); + + // The warm list stays rendered and selectable with no refresh/loading interstitial. + await expect + .poll(() => picker.locator("[data-chat-model-option]:visible").count()) + .toBeGreaterThanOrEqual(3); + await screenshot(page, "01-picker-open-refresh-in-flight.png"); + expect(await picker.locator("[data-chat-model-catalog-state]").count()).toBe(0); + expect( + await picker.locator('[data-chat-model-option="openai/gpt-5.6-luna"]').isDisabled(), + ).toBe(false); + + // The background result still owns the authoritative apply once it lands. + await gateway.resolveDeferred("models.list", { + models: [ + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", provider: "openai" }, + { id: "gpt-5.6-terra", name: "GPT-5.6 Terra", provider: "openai" }, + ], + }); + await picker + .locator('[data-chat-model-option="openai/gpt-5.6-terra"]') + .waitFor({ state: "visible" }); + expect(await picker.locator("[data-chat-model-catalog-state]").count()).toBe(0); + await screenshot(page, "02-picker-after-background-apply.png"); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index b8e245e9251d..1ab302c3bf74 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -5790,7 +5790,6 @@ export const en: TranslationMap = { chatOnlyHelp: "This model can chat, but it cannot use tools. Choose another model for files, commands, web, or media tasks.", loadingModels: "Loading models…", - refreshingModels: "Refreshing models…", modelsUnavailable: "Models unavailable", noModelsAvailable: "No models available", emptyModelsAction: "Manage models", diff --git a/ui/src/pages/chat/chat-pane-session-controls.test.ts b/ui/src/pages/chat/chat-pane-session-controls.test.ts index d1dea05ee652..cd6ea003b0c6 100644 --- a/ui/src/pages/chat/chat-pane-session-controls.test.ts +++ b/ui/src/pages/chat/chat-pane-session-controls.test.ts @@ -37,14 +37,24 @@ describe("chat model catalog state", () => { expected: { hasSnapshot: true, status: "ready" }, }, { - label: "refreshing with a cached snapshot", + label: "ready with a cached snapshot", state: { chatModelCatalog: cachedCatalog, chatModelCatalogError: null, + chatModelsLoading: false, + connected: true, + }, + expected: { hasSnapshot: true, status: "ready" }, + }, + { + label: "loading without a cached snapshot", + state: { + chatModelCatalog: [], + chatModelCatalogError: null, chatModelsLoading: true, connected: true, }, - expected: { hasSnapshot: true, status: "refreshing" }, + expected: { hasSnapshot: false, status: "loading" }, }, { label: "offline", @@ -477,48 +487,77 @@ describe("chat pane composer controls", () => { expect(state.requestUpdate).toHaveBeenCalledOnce(); }); - it("refreshes the configured model catalog when the picker opens", async () => { - const container = document.createElement("div"); - const request = vi.fn(async () => ({ models: [] })); - const state = { - chatRunId: null, - connected: true, - connectionEpoch: 1, - client: { request }, - chatLoading: false, - chatModelCatalog: [], - chatModelCatalogError: null, - sessions: { state: { modelOverrides: {} }, patch: vi.fn() }, - chatModelSwitchPromises: {}, - sessionKey: "main", - chatModelsLoading: false, - chatSending: false, - sessionsResult: null, - chatStream: null, - requestUpdate: vi.fn(), - } as unknown as ChatPageHost; - const controls = renderChatPaneComposerControls({ - state, - selectedSession: undefined, - agentDefaultModel: undefined, - modelAccess: { allowed: true, requiredScope: "operator.write" }, - effortAccess: { allowed: true, requiredScope: "operator.write" }, - permissionAccess: { allowed: true, requiredScope: "operator.write" }, - canSelectFull: true, - toastAnchor: document.createElement("div"), - onModelSetup: vi.fn(), - }); - render(controls.composerControls, container); + it.each([ + { + label: "warm", + cachedModels: [{ id: "cached-model", name: "Cached Model", provider: "openai" }], + }, + { label: "cold", cachedModels: [] }, + ])( + "revalidates the $label configured model catalog when the picker opens", + async ({ cachedModels }) => { + const container = document.createElement("div"); + const catalog = createDeferred<{ models: typeof cachedModels }>(); + const request = vi.fn(() => catalog.promise); + const state = { + chatRunId: null, + connected: true, + connectionEpoch: 1, + client: { request }, + chatLoading: false, + chatModelCatalog: cachedModels, + chatModelCatalogError: null, + sessions: { + state: { modelOverrides: {} }, + patch: vi.fn(), + refresh: vi.fn().mockResolvedValue(undefined), + }, + chatModelSwitchPromises: {}, + sessionKey: "main", + chatModelsLoading: false, + chatSending: false, + sessionsResult: null, + chatStream: null, + requestUpdate: vi.fn(), + } as unknown as ChatPageHost; + const controlParams = { + state, + selectedSession: undefined, + agentDefaultModel: undefined, + modelAccess: { allowed: true, requiredScope: "operator.write" } as const, + effortAccess: { allowed: true, requiredScope: "operator.write" } as const, + permissionAccess: { allowed: true, requiredScope: "operator.write" } as const, + canSelectFull: true, + toastAnchor: document.createElement("div"), + onModelSetup: vi.fn(), + }; + render(renderChatPaneComposerControls(controlParams).composerControls, container); - const picker = container.querySelector(".chat-controls__model-picker"); - picker!.open = true; - picker!.dispatchEvent(new Event("toggle")); + const picker = container.querySelector(".chat-controls__model-picker"); + picker!.open = true; + picker!.dispatchEvent(new Event("toggle")); - await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); - expect(request).toHaveBeenCalledWith("models.list", { - view: "configured", - agentId: "main", - refresh: true, - }); - }); + expect(request).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledWith("models.list", { + view: "configured", + agentId: "main", + refresh: true, + }); + expect(state.chatModelsLoading).toBe(cachedModels.length === 0); + render(renderChatPaneComposerControls(controlParams).composerControls, container); + if (cachedModels.length > 0) { + expect(container.querySelector("[data-chat-model-catalog-state]")).toBeNull(); + expect( + container.querySelector("[data-chat-model-option]")?.disabled, + ).toBe(false); + expect(container.textContent).toContain("Cached Model"); + } else { + expect(container.querySelector('[data-chat-model-catalog-state="loading"]')).not.toBeNull(); + expect(container.textContent).toContain("Loading models…"); + } + const freshModels = [{ id: "fresh-model", name: "Fresh Model", provider: "openai" }]; + catalog.resolve({ models: freshModels }); + await vi.waitFor(() => expect(state.chatModelCatalog).toEqual(freshModels)); + }, + ); }); diff --git a/ui/src/pages/chat/chat-pane-session-controls.ts b/ui/src/pages/chat/chat-pane-session-controls.ts index db200e1efee4..fe15006cb350 100644 --- a/ui/src/pages/chat/chat-pane-session-controls.ts +++ b/ui/src/pages/chat/chat-pane-session-controls.ts @@ -73,9 +73,7 @@ export function resolveChatModelCatalogState( : state.chatModelCatalogError ? "error" : state.chatModelsLoading - ? hasSnapshot - ? "refreshing" - : "loading" + ? "loading" : "ready", }; } diff --git a/ui/src/pages/chat/chat-pane.test-support.ts b/ui/src/pages/chat/chat-pane.test-support.ts index 70a5ba376e42..d7b0e5dd703c 100644 --- a/ui/src/pages/chat/chat-pane.test-support.ts +++ b/ui/src/pages/chat/chat-pane.test-support.ts @@ -337,6 +337,9 @@ export function createTestChatPane(params: { chatHistoryPagination: { hasMore: false }, chatLoading: false, chatMessages: [], + chatModelCatalog: [], + chatModelCatalogError: null, + chatModelsLoading: false, chatQueue: [], chatRunId: null, chatSending: false, diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index b1361b7b9bff..edaa64d8fdf4 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -500,7 +500,7 @@ describe("refreshChat", () => { await waitForFast(() => expect(host.chatModelCatalog).toEqual([model])); }); - it("renders cached models while startup metadata refreshes", async () => { + it("keeps cached models interactive while startup metadata revalidates silently", async () => { const startup = createDeferred(); const host = makeChatHost({ chatModelSwitchPromises: {}, @@ -527,7 +527,7 @@ describe("refreshChat", () => { }); expect(host.chatModelCatalog).toEqual([cachedModel]); - expect(asChatPageHost(host).chatModelsLoading).toBe(true); + expect(asChatPageHost(host).chatModelsLoading).toBe(false); const container = document.createElement("div"); const controls = renderChatPaneComposerControls({ state: asChatPageHost(host), @@ -541,8 +541,8 @@ describe("refreshChat", () => { onModelSetup: vi.fn(), }); render(controls.composerControls, container); - expect(container.querySelector('[data-chat-model-catalog-state="refreshing"]')).not.toBeNull(); - expect(container.textContent).toContain("Refreshing models…"); + expect(container.querySelector("[data-chat-model-catalog-state]")).toBeNull(); + expect(container.textContent).toContain("Cached Model"); expect(container.textContent).not.toContain("Loading models…"); startup.resolve({ diff --git a/ui/src/pages/chat/chat-state-refresh.ts b/ui/src/pages/chat/chat-state-refresh.ts index ccdbd4df6d56..41763048fa82 100644 --- a/ui/src/pages/chat/chat-state-refresh.ts +++ b/ui/src/pages/chat/chat-state-refresh.ts @@ -98,8 +98,7 @@ function seedChatModelCatalogFromStore(host: ChatPageHost, client: GatewayBrowse if (!Array.isArray(cached?.models)) { return; } - // A warm snapshot turns mount-time loading into refreshing; the in-flight - // request still owns the authoritative apply. + // A warm snapshot stays interactive; the in-flight request owns the authoritative apply. host.chatModelCatalog = cached.models; host.chatModelCatalogError = null; } @@ -130,8 +129,8 @@ export async function refreshChatMetadata( const client = host.client; const agentId = resolveChatAgentId(host); const request = { host, client, agentId, version: requestVersion }; - host.chatModelsLoading = true; seedChatModelCatalogFromStore(host, client); + host.chatModelsLoading = host.chatModelCatalog.length === 0; try { const result = await loadChatMetadata(client, agentId); if (!ownsChatMetadataRequest(request)) { @@ -186,7 +185,7 @@ export async function refreshChatModelCatalogOnDemand(host: ChatPageHost): Promi host.connected && host.connectionEpoch === connectionEpoch && resolveChatAgentId(host) === agentId; - host.chatModelsLoading = true; + host.chatModelsLoading = host.chatModelCatalog.length === 0; host.chatModelCatalogError = null; host.requestUpdate?.(); try { @@ -334,8 +333,8 @@ export function refreshPageChat(host: ChatPageHost, opts?: ChatRefreshOptions) { ? ++host.chatMetadataRequestVersion : null; if (ownsStartupMetadata && host.client) { - host.chatModelsLoading = true; seedChatModelCatalogFromStore(host, host.client); + host.chatModelsLoading = host.chatModelCatalog.length === 0; } const refresh = refreshChat(host, { diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index 6f82acdb8f73..98e0c957ec8e 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -2406,41 +2406,51 @@ describe("refreshChatMetadata", () => { } as unknown as ChatPageHost; } - it("refreshes session metadata after full model discovery completes", async () => { - const refreshSessions = vi.fn().mockResolvedValue(undefined); - const request = vi.fn(async (method: string, params?: unknown) => { - expect(method).toBe("models.list"); - expect(params).toEqual({ view: "configured", agentId: "work", refresh: true }); - return { - models: [ - { - id: "reasoner", - name: "Reasoner", - provider: "dynamic-router", - reasoning: true, - }, - ], - }; - }); - const state = createMetadataState(request, { - sessions: { refresh: refreshSessions } as never, - }); + it.each([ + { + label: "warm", + existingModels: [{ id: "cached-model", name: "Cached Model", provider: "openai" }], + }, + { label: "cold", existingModels: [] }, + ])( + "refreshes $label session metadata after full model discovery completes", + async ({ existingModels }) => { + const refreshSessions = vi.fn().mockResolvedValue(undefined); + const discovery = createDeferred<{ + models: Array<{ id: string; name: string; provider: string; reasoning: boolean }>; + }>(); + const request = vi.fn((method: string, params?: unknown) => { + expect(method).toBe("models.list"); + expect(params).toEqual({ view: "configured", agentId: "work", refresh: true }); + return discovery.promise; + }); + const state = createMetadataState(request, { + chatModelCatalog: existingModels, + sessions: { refresh: refreshSessions } as never, + }); - await refreshChatModelCatalogOnDemand(state); + const refresh = refreshChatModelCatalogOnDemand(state); + expect(state.chatModelCatalog).toEqual(existingModels); + expect(state.chatModelsLoading).toBe(existingModels.length === 0); + discovery.resolve({ + models: [{ id: "reasoner", name: "Reasoner", provider: "dynamic-router", reasoning: true }], + }); + await refresh; - expect(state.chatModelCatalog).toEqual([ - { - id: "reasoner", - name: "Reasoner", - provider: "dynamic-router", - reasoning: true, - }, - ]); - expect(refreshSessions).toHaveBeenCalledWith( - expect.objectContaining({ agentId: "work", force: true }), - ); - expect(state.chatModelCatalogError).toBeNull(); - }); + expect(state.chatModelCatalog).toEqual([ + { + id: "reasoner", + name: "Reasoner", + provider: "dynamic-router", + reasoning: true, + }, + ]); + expect(refreshSessions).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "work", force: true }), + ); + expect(state.chatModelCatalogError).toBeNull(); + }, + ); it("applies agent-scoped metadata after a same-agent session switch", async () => { let resolveMetadata: diff --git a/ui/src/pages/chat/components/chat-model-controls.ts b/ui/src/pages/chat/components/chat-model-controls.ts index 4b6b81b6b42f..e19b23f04e8e 100644 --- a/ui/src/pages/chat/components/chat-model-controls.ts +++ b/ui/src/pages/chat/components/chat-model-controls.ts @@ -170,7 +170,7 @@ function resolveCatalogTriggerStatus( if (state.status === "error") { return optionCount === 0 ? t("chat.modelControls.modelsUnavailable") : undefined; } - if (!state.hasSnapshot && ["idle", "loading", "refreshing"].includes(state.status)) { + if (!state.hasSnapshot && ["idle", "loading"].includes(state.status)) { return t("chat.modelControls.loadingModels"); } if (state.hasSnapshot && optionCount === 0) { @@ -354,8 +354,7 @@ export function renderChatModelControls(props: ChatModelControlsProps) { status: props.modelsLoading ? ("loading" as const) : ("ready" as const), }; const catalogLoadingWithoutSnapshot = - !managedCatalog.hasSnapshot && - ["idle", "loading", "refreshing"].includes(managedCatalog.status); + !managedCatalog.hasSnapshot && ["idle", "loading"].includes(managedCatalog.status); const catalogTriggerStatus = resolveCatalogTriggerStatus(managedCatalog, modelOptions.length); const busy = props.loading || props.sending || Boolean(props.activeRunId) || props.stream !== null; diff --git a/ui/src/pages/chat/components/chat-model-picker.ts b/ui/src/pages/chat/components/chat-model-picker.ts index d43a7ab9370b..5a977d376431 100644 --- a/ui/src/pages/chat/components/chat-model-picker.ts +++ b/ui/src/pages/chat/components/chat-model-picker.ts @@ -23,7 +23,7 @@ import { handleChatComposerDetailsToggle, syncChatPickerOverlay } from "./chat-p export type ChatModelCatalogState = { hasSnapshot: boolean; - status: "idle" | "loading" | "refreshing" | "ready" | "error" | "offline"; + status: "idle" | "loading" | "ready" | "error" | "offline"; }; type ChatModelPickerParams = { @@ -250,15 +250,13 @@ function renderCatalogState( const label = state.status === "offline" ? t("common.offline") - : state.status === "refreshing" - ? t("chat.modelControls.refreshingModels") - : state.status === "error" - ? errorLabel - : state.status === "ready" - ? hasOptions - ? `${t("modelSetup.failure.auth")}. ${t("modelSetup.failureGuidance.auth")}` - : t("chat.modelControls.noModelsAvailable") - : t("chat.modelControls.loadingModels"); + : state.status === "error" + ? errorLabel + : state.status === "ready" + ? hasOptions + ? `${t("modelSetup.failure.auth")}. ${t("modelSetup.failureGuidance.auth")}` + : t("chat.modelControls.noModelsAvailable") + : t("chat.modelControls.loadingModels"); return html`
{ }); let container = renderControl(control, context); - expect(container.querySelector('[data-chat-model-catalog-state="refreshing"]')).not.toBeNull(); + expect(container.querySelector("[data-chat-model-catalog-state]")).toBeNull(); expect(container.querySelectorAll("[data-chat-model-option]")).toHaveLength(2); + expect( + container.querySelector('[data-chat-model-option="openai/gpt-5.6-sol"]') + ?.disabled, + ).toBe(false); container .querySelector('[data-chat-model-option="openai/gpt-5.6-sol"]') diff --git a/ui/src/pages/new-session/model-control.ts b/ui/src/pages/new-session/model-control.ts index 2be1f2444a98..513ad03cd4ef 100644 --- a/ui/src/pages/new-session/model-control.ts +++ b/ui/src/pages/new-session/model-control.ts @@ -284,11 +284,11 @@ export class NewSessionModelControl { }; const cached = peekChatMetadata(client, agentId); if (Array.isArray(cached?.models)) { - this.publishMetadataCatalog(cached.models, "refreshing"); + this.publishMetadataCatalog(cached.models, "ready"); } else { this.updateMetadataState({ ...this.metadataState, - status: this.metadataState.hasSnapshot ? "refreshing" : "loading", + status: this.metadataState.hasSnapshot ? "ready" : "loading", }); } @@ -463,10 +463,7 @@ export class NewSessionModelControl { const cached = peekChatMetadata(client, normalizedAgentId); if (activeRequestMatches) { if (cached) { - this.publishMetadataCatalog( - Array.isArray(cached.models) ? cached.models : [], - "refreshing", - ); + this.publishMetadataCatalog(Array.isArray(cached.models) ? cached.models : [], "ready"); } else { this.notify(); }