diff --git a/ui/src/e2e/chat-composer-redesign.e2e.test.ts b/ui/src/e2e/chat-composer-redesign.e2e.test.ts index 059fb407b7b8..a8b16ea02abf 100644 --- a/ui/src/e2e/chat-composer-redesign.e2e.test.ts +++ b/ui/src/e2e/chat-composer-redesign.e2e.test.ts @@ -573,19 +573,11 @@ suite.define(() => { }); }); - it("refreshes the configured usable catalog after advertised chat metadata", async () => { + it("uses the live usable catalog instead of unavailable metadata models", async () => { await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => { const gateway = await installMockGateway(page, { - agentModel: "openai/gpt-5.3-codex-spark", - models: [ - { id: "gpt-5.5", name: "GPT-5.5", provider: "openai", available: true }, - { - id: "gpt-5.3-codex-spark", - name: "GPT-5.3 Codex Spark", - provider: "codex", - available: false, - }, - ], + agentModel: "openai/gpt-5.6-sol", + models: [{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai", available: true }], methodResponses: { "chat.startup": { agentsList: { @@ -603,9 +595,9 @@ suite.define(() => { models: [ { id: "gpt-5.5", name: "GPT-5.5", provider: "openai", available: true }, { - id: "gpt-5.3-codex-spark", - name: "GPT-5.3 Codex Spark", - provider: "codex", + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + provider: "openai", available: false, }, ], @@ -614,7 +606,7 @@ suite.define(() => { count: 1, defaults: { contextTokens: 200_000, - model: "gpt-5.3-codex-spark", + model: "gpt-5.6-sol", modelProvider: "openai", }, path: "", @@ -640,7 +632,10 @@ suite.define(() => { await page.goto(`${suite.server.baseUrl}chat`); await gateway.waitForRequest("chat.metadata"); - expect(await gateway.getRequests("models.list")).toHaveLength(0); + await gateway.waitForRequest("models.list"); + expect(await gateway.getRequests("models.list")).toEqual([ + expect.objectContaining({ params: { view: "configured" } }), + ]); const composer = page.locator(".agent-chat__input"); const providers = composer.locator("[data-chat-model-provider]"); @@ -651,12 +646,21 @@ suite.define(() => { .poll(() => composer.locator('[data-chat-model-provider-group="openai"]').textContent()) .toContain("GPT-5.5"); await expect - .poll(() => composer.locator('[data-chat-model-provider-group="codex"]').count()) + .poll(() => composer.locator('[data-chat-model-option="openai/gpt-5.6-sol"]').count()) .toBe(0); // The advertised default is unavailable, so no usable catalog row is // marked as the default and no synthetic empty row is introduced. await expect.poll(() => composer.locator('[data-chat-model-default="true"]').count()).toBe(0); await expect.poll(() => composer.locator('[data-chat-model-option=""]').count()).toBe(0); + const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); + if (artifactDir) { + await composer.locator('[data-chat-model-select="true"]').click(); + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: `${artifactDir}/usable-model-picker.png`, + }); + } }); }); diff --git a/ui/src/e2e/model-alias-display.e2e.test.ts b/ui/src/e2e/model-alias-display.e2e.test.ts index 0166d1be9ba0..8dd6620077a5 100644 --- a/ui/src/e2e/model-alias-display.e2e.test.ts +++ b/ui/src/e2e/model-alias-display.e2e.test.ts @@ -142,9 +142,9 @@ suite.define(() => { expect(response?.status()).toBe(200); await gateway.waitForRequest("agents.list"); await gateway.waitForRequest("config.get"); - const modelRequest = await gateway.waitForRequest("chat.metadata"); - expect(modelRequest.params).toEqual({ agentId: "main" }); - expect(await gateway.getRequests("models.list")).toHaveLength(0); + const modelRequest = await gateway.waitForRequest("models.list"); + expect(modelRequest.params).toEqual({ agentId: "main", view: "configured" }); + expect(await gateway.getRequests("chat.metadata")).toHaveLength(0); const select = page.locator("select.settings-select").first(); await select.waitFor({ state: "visible", timeout: 10_000 }); diff --git a/ui/src/e2e/new-session-page.catalog-reconnect.e2e.test.ts b/ui/src/e2e/new-session-page.catalog-reconnect.e2e.test.ts index 96e3c465f790..1cf37343ac4c 100644 --- a/ui/src/e2e/new-session-page.catalog-reconnect.e2e.test.ts +++ b/ui/src/e2e/new-session-page.catalog-reconnect.e2e.test.ts @@ -35,8 +35,8 @@ function requestHasParam(request: { params?: unknown }, key: string, value: unkn } const TERMINAL_START_FEATURE_METHODS = [ - "chat.metadata", "chat.startup", + "models.list", "sessions.catalog.list", "sessions.catalog.startTerminal", "sessions.create", @@ -78,8 +78,8 @@ suite.define(() => { const gateway = await installMockGateway(page, { cliAgentsEnabled: true, featureMethods: [ - "chat.metadata", "chat.startup", + "models.list", "sessions.create", "sessions.dispatch", "sessions.catalog.list", @@ -450,7 +450,7 @@ suite.define(() => { const gateway = await installMockGateway(page, { agentModel: "openai/gpt-5.6-luna", methodResponses: { - "chat.metadata": { + "models.list": { sequence: [ { __mockError: { @@ -458,7 +458,7 @@ suite.define(() => { message: "metadata request timed out", }, }, - { commands: [], models }, + { models }, ], }, }, @@ -467,7 +467,7 @@ suite.define(() => { try { await page.goto(`${suite.server.baseUrl}new`); - await gateway.waitForRequest("chat.metadata"); + await gateway.waitForRequest("models.list"); const modelSelect = page.locator('[data-chat-model-select="true"]'); await expect.poll(() => modelSelect.textContent()).toContain("Models unavailable"); @@ -479,7 +479,7 @@ suite.define(() => { await page.locator('[data-chat-model-catalog-retry="true"]').click(); - await expect.poll(async () => (await gateway.getRequests("chat.metadata")).length).toBe(2); + await expect.poll(async () => (await gateway.getRequests("models.list")).length).toBe(2); await expect.poll(() => page.locator("[data-chat-model-option]").count()).toBe(3); expect(await page.locator('[data-chat-model-catalog-state="error"]').count()).toBe(0); } finally { @@ -503,7 +503,7 @@ suite.define(() => { }; const gateway = await installMockGateway(page, { methodResponses: { - "chat.metadata": { + "models.list": { sequence: [ { __mockError: { @@ -514,7 +514,7 @@ suite.define(() => { retryAfterMs: 100, }, }, - { commands: [], models: [recoveredModel] }, + { models: [recoveredModel] }, ], }, }, @@ -522,7 +522,7 @@ suite.define(() => { try { await page.goto(`${suite.server.baseUrl}new`); - await expect.poll(async () => (await gateway.getRequests("chat.metadata")).length).toBe(2); + await expect.poll(async () => (await gateway.getRequests("models.list")).length).toBe(2); const modelSelect = page.locator( '.new-session-page__composer [data-chat-model-select="true"]', @@ -532,9 +532,9 @@ suite.define(() => { .poll(() => page.locator('[data-chat-model-option="openai/gpt-5.6-luna"]').textContent()) .toContain(recoveredModel.name); - expect(await gateway.getRequests("chat.metadata")).toEqual([ - expect.objectContaining({ params: { agentId: "main" } }), - expect.objectContaining({ params: { agentId: "main" } }), + expect(await gateway.getRequests("models.list")).toEqual([ + expect.objectContaining({ params: { agentId: "main", view: "configured" } }), + expect.objectContaining({ params: { agentId: "main", view: "configured" } }), ]); } finally { await context.close(); diff --git a/ui/src/e2e/new-session-page.cloud-startup.e2e.test.ts b/ui/src/e2e/new-session-page.cloud-startup.e2e.test.ts index bdff0ae58f54..f3bc1e02c3a2 100644 --- a/ui/src/e2e/new-session-page.cloud-startup.e2e.test.ts +++ b/ui/src/e2e/new-session-page.cloud-startup.e2e.test.ts @@ -45,6 +45,7 @@ suite.define(() => { featureMethods: [ "chat.metadata", "chat.startup", + "models.list", "sessions.create", "sessions.dispatch", "sessions.reclaim", diff --git a/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts b/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts index c81eac73b906..75308ab96398 100644 --- a/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts +++ b/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts @@ -13,6 +13,7 @@ async function openDraft( featureMethods = [ "chat.metadata", "chat.startup", + "models.list", "projects.list", "sessions.create", "sessions.dispatch", @@ -41,7 +42,14 @@ suite.define(() => { it("keeps read-scoped operators out of new-session entry and submission paths", async () => { const { context, gateway, page } = await openDraft( ["operator.read"], - ["chat.metadata", "chat.startup", "projects.list", "sessions.create", "sessions.dispatch"], + [ + "chat.metadata", + "chat.startup", + "models.list", + "projects.list", + "sessions.create", + "sessions.dispatch", + ], ); try { const sidebarCreate = page.locator(".sidebar-brand__new-thread"); @@ -136,6 +144,7 @@ suite.define(() => { featureMethods: [ "chat.metadata", "chat.startup", + "models.list", "fs.listDir", "sessions.create", "worktrees.branches", @@ -197,6 +206,7 @@ suite.define(() => { featureMethods: [ "chat.metadata", "chat.startup", + "models.list", "fs.listDir", "sessions.create", "worktrees.branches", @@ -264,6 +274,7 @@ suite.define(() => { featureMethods: [ "chat.metadata", "chat.startup", + "models.list", "fs.listDir", "sessions.create", "worktrees.branches", @@ -328,7 +339,7 @@ suite.define(() => { it("blocks creation when the connected Gateway explicitly omits sessions.create", async () => { const { context, gateway, page } = await openDraft( ["operator.admin", "operator.read", "operator.write"], - ["chat.metadata", "chat.startup"], + ["chat.metadata", "chat.startup", "models.list"], ); try { await expect.poll(() => page.locator(".sidebar-brand__new-thread").isDisabled()).toBe(true); diff --git a/ui/src/e2e/new-session-page.places.e2e.test.ts b/ui/src/e2e/new-session-page.places.e2e.test.ts index 49dbc0be6c5f..e3c6556a0043 100644 --- a/ui/src/e2e/new-session-page.places.e2e.test.ts +++ b/ui/src/e2e/new-session-page.places.e2e.test.ts @@ -359,6 +359,7 @@ suite.define(() => { featureMethods: [ "chat.metadata", "chat.startup", + "models.list", "sessions.create", "sessions.dispatch", "projects.list", @@ -480,6 +481,7 @@ suite.define(() => { featureMethods: [ "chat.metadata", "chat.startup", + "models.list", "fs.listDir", "projects.list", "projects.register", @@ -543,7 +545,13 @@ suite.define(() => { workspace: WORKSPACE, workspaceGit: true, operatorScopes: ["operator.read", "operator.write"], - featureMethods: ["chat.metadata", "chat.startup", "projects.list", "sessions.create"], + featureMethods: [ + "chat.metadata", + "chat.startup", + "models.list", + "projects.list", + "sessions.create", + ], methodResponses: { "projects.list": { projects: [] } }, }); @@ -567,7 +575,13 @@ suite.define(() => { const gateway = await installMockGateway(page, { workspace: WORKSPACE, workspaceGit: true, - featureMethods: ["chat.metadata", "chat.startup", "sessions.create", "system.info"], + featureMethods: [ + "chat.metadata", + "chat.startup", + "models.list", + "sessions.create", + "system.info", + ], methodResponses: { "system.info": { machineName: "Peters-Mac-Studio", diff --git a/ui/src/e2e/new-session-page.test-support.ts b/ui/src/e2e/new-session-page.test-support.ts index 22950c8175a9..6c5e0409015a 100644 --- a/ui/src/e2e/new-session-page.test-support.ts +++ b/ui/src/e2e/new-session-page.test-support.ts @@ -16,8 +16,8 @@ import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts" export { controlUiSessionPath, controlUiSessionUrl, waitForConfirmModal }; const NEW_SESSION_FEATURE_METHODS = [ - "chat.metadata", "chat.startup", + "models.list", "sessions.create", "sessions.dispatch", ] as const; diff --git a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts index 087f4afe15d8..d4a690dc69b5 100644 --- a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts +++ b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts @@ -728,15 +728,15 @@ suite.define(() => { await navigateInApp(page, "chat"); await waitForCommittedChatRoute(page); - const metadataRequests = (await gateway.getRequests("chat.metadata")).length; + const modelRequests = (await gateway.getRequests("models.list")).length; const branchRequests = (await gateway.getRequests("worktrees.branches")).length; - await gateway.deferNext("chat.metadata"); + await gateway.deferNext("models.list"); await gateway.deferNext("worktrees.branches"); await navigateInApp(page, "new-session"); await expect.poll(() => new URL(page.url()).pathname).toBe("/new"); await expect - .poll(async () => (await gateway.getRequests("chat.metadata")).length) - .toBe(metadataRequests + 1); + .poll(async () => (await gateway.getRequests("models.list")).length) + .toBe(modelRequests + 1); await expect .poll(async () => (await gateway.getRequests("worktrees.branches")).length) .toBe(branchRequests + 1); @@ -745,7 +745,7 @@ suite.define(() => { const start = page.getByRole("button", { name: "Start session" }); await expect.poll(() => start.isDisabled()).toBe(true); - await gateway.resolveDeferred("chat.metadata", { models }); + await gateway.resolveDeferred("models.list", { models }); await expect.poll(() => start.isDisabled()).toBe(true); await gateway.rejectDeferred("worktrees.branches", { code: "UNAVAILABLE", @@ -828,6 +828,7 @@ suite.define(() => { featureMethods: [ "chat.metadata", "chat.startup", + "models.list", "fs.listDir", "sessions.create", "worktrees.branches", diff --git a/ui/src/pages/chat/chat-state-refresh.ts b/ui/src/pages/chat/chat-state-refresh.ts index cae8ec366f02..71753c9881a8 100644 --- a/ui/src/pages/chat/chat-state-refresh.ts +++ b/ui/src/pages/chat/chat-state-refresh.ts @@ -267,7 +267,12 @@ export async function refreshChatMetadata( if (!ownsChatMetadataRequest(request)) { return EMPTY_CHAT_METADATA_APPLY_RESULT; } - const metadataApplied = applyChatMetadataResult(host, client, agentId, result); + // chat.metadata remains the compatibility source for commands only. Picker inventory must + // always come from the live, agent-scoped models.list result so stale static models cannot + // reappear when chat.startup omits metadata or an older Gateway serves this fallback path. + const metadataApplied = applyChatMetadataResult(host, client, agentId, result, { + models: false, + }); if (!metadataApplied.models || !metadataApplied.commands) { await refreshMissingChatMetadata(request, metadataApplied, opts); } diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index c1b646aaee2d..c5b89a56a23e 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -1434,7 +1434,17 @@ describe("refreshChatMetadata", () => { }>((resolve) => { resolveMetadata = resolve; }); + const liveModel = { + id: "work-model", + name: "Work Model", + provider: "openai", + available: true, + }; const request = vi.fn(async (method: string, params?: unknown) => { + if (method === "models.list") { + expect(params).toEqual({ agentId: "work", view: "configured" }); + return { models: [liveModel] }; + } expect(method).toBe("chat.metadata"); expect(params).toEqual({ agentId: "work" }); return await metadata; @@ -1445,38 +1455,37 @@ describe("refreshChatMetadata", () => { state.sessionKey = "agent:work:another"; resolveMetadata?.({ commands: [], - models: [{ id: "work-model", name: "Work Model", provider: "openai", available: true }], + models: [{ id: "stale-model", name: "Stale Model", provider: "openai", available: false }], }); await refresh; - expect(state.chatModelCatalog).toEqual([ - { id: "work-model", name: "Work Model", provider: "openai", available: true }, - ]); - expect(request).toHaveBeenCalledTimes(1); + expect(state.chatModelCatalog).toEqual([liveModel]); + expect(request).toHaveBeenCalledTimes(2); }); it("reuses same-agent metadata and fetches a cross-agent catalog", async () => { - const request = vi.fn(async (_method: string, params?: { agentId?: string }) => ({ - commands: [], - models: [ - { - id: `${params?.agentId}-model`, - name: `${params?.agentId} Model`, - provider: "openai", - }, - ], - })); + const request = vi.fn(async (method: string, params?: { agentId?: string }) => { + const model = { + id: `${params?.agentId}-model`, + name: `${params?.agentId} Model`, + provider: "openai", + }; + return method === "models.list" ? { models: [model] } : { commands: [], models: [model] }; + }); const state = createMetadataState(request); await refreshChatMetadata(state); state.sessionKey = "agent:work:second"; await refreshChatMetadata(state); - expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledTimes(2); state.sessionKey = "agent:other:main"; await refreshChatMetadata(state); - expect(request).toHaveBeenCalledTimes(2); - expect(request).toHaveBeenLastCalledWith("chat.metadata", { agentId: "other" }); + expect(request).toHaveBeenCalledTimes(4); + expect(request).toHaveBeenLastCalledWith("models.list", { + agentId: "other", + view: "configured", + }); }); it("ignores metadata after switching to a different agent", async () => { @@ -1531,10 +1540,21 @@ describe("refreshChatMetadata", () => { }>((resolve) => { resolveOther = resolve; }); - const request = vi.fn( - async (_method: string, params?: { agentId?: string }) => - await (params?.agentId === "work" ? workMetadata : otherMetadata), - ); + const request = vi.fn(async (method: string, params?: { agentId?: string }) => { + if (method === "models.list") { + const isWork = params?.agentId === "work"; + return { + models: [ + { + id: isWork ? "work-model" : "other-model", + name: isWork ? "Work Model" : "Other Model", + provider: "openai", + }, + ], + }; + } + return await (params?.agentId === "work" ? workMetadata : otherMetadata); + }); const state = createMetadataState(request); const workRefresh = refreshChatMetadata(state); @@ -1580,10 +1600,13 @@ describe("refreshChatMetadata", () => { }>((resolve) => { resolveSecond = resolve; }); - let requestCount = 0; - const request = vi.fn(async () => { - requestCount += 1; - return await (requestCount === 1 ? firstMetadata : secondMetadata); + let metadataRequestCount = 0; + const request = vi.fn(async (method: string) => { + if (method === "models.list") { + return { models: [{ id: "new-model", name: "New Model", provider: "openai" }] }; + } + metadataRequestCount += 1; + return await (metadataRequestCount === 1 ? firstMetadata : secondMetadata); }); const state = createMetadataState(request); @@ -1728,6 +1751,9 @@ describe("refreshChatMetadata", () => { resolveCommands = resolve; }); const request = vi.fn(async (method: string) => { + if (method === "models.list") { + return { models: [] }; + } expect(method).toBe("commands.list"); return await commands; }); diff --git a/ui/src/pages/new-session/model-control.test.ts b/ui/src/pages/new-session/model-control.test.ts index 0605b4fc690e..163d0c2f0c75 100644 --- a/ui/src/pages/new-session/model-control.test.ts +++ b/ui/src/pages/new-session/model-control.test.ts @@ -332,7 +332,7 @@ describe("new-session model runtime", () => { it.each([ ["generic transport error", new Error("metadata unavailable")], - ["request timeout", new Error("gateway request timeout for chat.metadata")], + ["request timeout", new Error("gateway request timeout for models.list")], ])("renders %s as unavailable instead of a default-only catalog", async (_label, error) => { const { context, request } = contextWith([]); request.mockRejectedValueOnce(error); diff --git a/ui/src/pages/new-session/model-control.ts b/ui/src/pages/new-session/model-control.ts index 0151cc34d589..89d6b4c5e69e 100644 --- a/ui/src/pages/new-session/model-control.ts +++ b/ui/src/pages/new-session/model-control.ts @@ -608,7 +608,7 @@ export class NewSessionModelControl { loading: false, modelCatalog: this.catalog, modelCatalogState: { - // chat.metadata and agents.list hydrate independently. Do not expose a + // models.list and agents.list hydrate independently. Do not expose a // ready catalog until the selected agent can supply its concrete defaults. hasSnapshot: agentDefaultsAvailable && this.metadataState.hasSnapshot, ...(this.metadataState.status === "error" ? { onRetry: this.retryMetadata } : {}),