diff --git a/ui/src/pages/agents/agents-page.test.ts b/ui/src/pages/agents/agents-page.test.ts index 126695775a3b..dac9d5a9f70d 100644 --- a/ui/src/pages/agents/agents-page.test.ts +++ b/ui/src/pages/agents/agents-page.test.ts @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import { describe, expect, it, vi } from "vitest"; -import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { GatewayBrowserClient, GatewayHelloOk } from "../../api/gateway.ts"; import type { AgentsFilesListResult, AgentsListResult, @@ -68,8 +68,12 @@ function setPageGateway( client: GatewayBrowserClient | null, connected = true, sourceChanged = false, + featureMethods?: string[], ) { - page.gateway.applySnapshot(snapshot(client, connected), { initial: false, sourceChanged }); + page.gateway.applySnapshot(snapshot(client, connected, featureMethods), { + initial: false, + sourceChanged, + }); } function deferred() { @@ -83,13 +87,14 @@ function deferred() { function snapshot( client: GatewayBrowserClient | null, connected = true, + featureMethods?: string[], ): ApplicationGatewaySnapshot { return { client, phase: connected ? "connected" : "stopped", offlineStable: false, canvasPluginSurfaceUrl: null, - hello: null, + hello: featureMethods ? ({ features: { methods: featureMethods } } as GatewayHelloOk) : null, assistantAgentId: null, sessionKey: "main", lastError: null, @@ -331,6 +336,31 @@ describe("AgentsPage gateway lifecycle", () => { expect(request).toHaveBeenCalledWith("models.list", { view: "configured", agentId: "main" }); }); + it("uses agent-scoped chat metadata when a legacy Gateway lacks models.list", async () => { + const request = vi.fn(async () => ({ + models: [ + { id: "gpt-5.5", name: "GPT-5.5", provider: "openai", available: true }, + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", provider: "openai", available: false }, + ], + })); + const page = document.createElement("openclaw-agents-page") as TestAgentsPage; + page.routeData = { panel: "overview" } as AgentsRouteData; + setPageGateway(page, { request } as unknown as GatewayBrowserClient, true, false, [ + "chat.metadata", + ]); + page.agentsSelectedId = "main"; + + page.loadActivePanelData(); + + await vi.waitFor(() => + expect(page.chatModelCatalog).toEqual([ + { id: "gpt-5.5", name: "GPT-5.5", provider: "openai", available: true }, + ]), + ); + expect(request).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledWith("chat.metadata", { agentId: "main" }); + }); + it("caches separate configured model catalogs for the default and worker agents", async () => { const defaultModels = [ { id: "default-model", name: "Default account model", provider: "openai" }, diff --git a/ui/src/pages/agents/agents-page.ts b/ui/src/pages/agents/agents-page.ts index 0e955f273cff..25612be8be51 100644 --- a/ui/src/pages/agents/agents-page.ts +++ b/ui/src/pages/agents/agents-page.ts @@ -40,13 +40,14 @@ import { } from "../../lib/cron/index.ts"; import { canCallGatewayMethod, + isGatewayMethodAdvertised, type GatewayMethodOperatorScope, } from "../../lib/gateway-methods.ts"; import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; import { GatewayPageController } from "../../lit/gateway-page-controller.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; -import { loadModels } from "../chat/models.ts"; +import { loadModelPickerModels } from "../chat/models.ts"; import { loadAgentFileContent, saveAgentFile } from "./files.ts"; import { resetIdentityDraft, @@ -575,7 +576,11 @@ class AgentsPage const request = { client, generation, agentId }; this.chatModelCatalogRequest = request; this.chatModelCatalogError = null; - void loadModels(client, { agentId, refresh: true }) + void loadModelPickerModels(client, { + agentId, + modelsListAdvertised: isGatewayMethodAdvertised(this.gateway.snapshot ?? {}, "models.list"), + refresh: true, + }) .then((models) => { if (this.isCurrentRequest(client, generation, agentId)) { this.chatModelCatalog = models; diff --git a/ui/src/pages/chat/models.ts b/ui/src/pages/chat/models.ts index 5d62d125c110..de10e045abc5 100644 --- a/ui/src/pages/chat/models.ts +++ b/ui/src/pages/chat/models.ts @@ -73,6 +73,23 @@ export async function loadModels( return inFlight; } +export async function loadModelPickerModels( + client: GatewayBrowserClient, + opts: { + agentId?: string; + modelsListAdvertised: boolean | null; + refresh?: boolean; + signal?: AbortSignal; + timeoutMs?: number; + }, +): Promise { + const models = + opts.modelsListAdvertised === false + ? await requestMetadataModels(client, opts) + : await loadModels(client, opts); + return models.filter((entry) => entry.available !== false); +} + export function applyModelCatalogResult(models: unknown): ModelCatalogEntry[] | null { if (!Array.isArray(models)) { return null; @@ -100,3 +117,25 @@ async function requestModels( : await client.request<{ models: ModelCatalogEntry[] }>("models.list", params); return result?.models ?? []; } + +async function requestMetadataModels( + client: GatewayBrowserClient, + opts: { agentId?: string; signal?: AbortSignal; timeoutMs?: number }, +): Promise { + const requestOptions = + opts.signal || opts.timeoutMs + ? { + ...(opts.signal ? { signal: opts.signal } : {}), + ...(opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {}), + } + : undefined; + const params = opts.agentId ? { agentId: opts.agentId } : {}; + const result = requestOptions + ? await client.request<{ models?: ModelCatalogEntry[] }>( + "chat.metadata", + params, + requestOptions, + ) + : await client.request<{ models?: ModelCatalogEntry[] }>("chat.metadata", params); + return result?.models ?? []; +} diff --git a/ui/src/pages/new-session/model-control.test.ts b/ui/src/pages/new-session/model-control.test.ts index 163d0c2f0c75..a3f18126b47d 100644 --- a/ui/src/pages/new-session/model-control.test.ts +++ b/ui/src/pages/new-session/model-control.test.ts @@ -12,7 +12,7 @@ import { NewSessionModelControl } from "./model-control.ts"; function contextWith( models: ModelCatalogEntry[], runtime = "openclaw", - featureMethods: string[] = [], + featureMethods: string[] = ["models.list"], ) { const request = vi.fn().mockResolvedValue({ models }); const context = { @@ -408,6 +408,30 @@ describe("new-session model runtime", () => { expect(container.querySelector('[data-chat-model-catalog-retry="true"]')).toBeNull(); }); + it("uses agent-scoped chat metadata when a legacy Gateway lacks models.list", async () => { + const models: ModelCatalogEntry[] = [ + { id: "gpt-5.5", name: "GPT-5.5", provider: "openai", available: true }, + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", provider: "openai", available: false }, + ]; + const { context, request } = contextWith(models, "openclaw", ["chat.metadata"]); + const control = new NewSessionModelControl(() => undefined); + + control.load(context, "main", true); + + await vi.waitFor(() => + expect( + renderControl(control, context).querySelectorAll("[data-chat-model-option]"), + ).toHaveLength(1), + ); + expect(request).toHaveBeenCalledWith( + "chat.metadata", + { agentId: "main" }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(request.mock.calls.map(([method]) => method)).toEqual(["chat.metadata"]); + expect(renderControl(control, context).textContent).not.toContain("GPT-5.6 Sol"); + }); + it("keeps a successful empty catalog explicit when its refresh fails", async () => { const refresh = deferred<{ models: ModelCatalogEntry[] }>(); const { context, request } = contextWith([]); diff --git a/ui/src/pages/new-session/model-control.ts b/ui/src/pages/new-session/model-control.ts index 89d6b4c5e69e..5c481d34df01 100644 --- a/ui/src/pages/new-session/model-control.ts +++ b/ui/src/pages/new-session/model-control.ts @@ -21,7 +21,7 @@ import { renderChatModelControls, type ChatModelCatalogState, } from "../chat/components/chat-model-controls.ts"; -import { loadModels } from "../chat/models.ts"; +import { loadModelPickerModels } from "../chat/models.ts"; import type { NewSessionPreference } from "./preferences.ts"; const NEW_SESSION_METADATA_RETRY_WINDOW_MS = 60_000; @@ -88,6 +88,7 @@ async function requestNewSessionMetadata( client: NewSessionMetadataClient, agentId: string, signal: AbortSignal, + modelsListAdvertised: boolean | null, ): Promise<{ models?: ModelCatalogEntry[] }> { const deadlineAt = Date.now() + NEW_SESSION_METADATA_RETRY_WINDOW_MS; let latestStartupError: Error | undefined; @@ -106,8 +107,9 @@ async function requestNewSessionMetadata( } try { - const models = await loadModels(client, { + const models = await loadModelPickerModels(client, { agentId, + modelsListAdvertised, refresh: true, signal, timeoutMs: Math.min(DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS, remainingMs), @@ -316,7 +318,11 @@ export class NewSessionModelControl { this.notify(); } - private startMetadataRequest(client: NewSessionMetadataClient, agentId: string) { + private startMetadataRequest( + client: NewSessionMetadataClient, + agentId: string, + modelsListAdvertised: boolean | null, + ) { this.cancelMetadataRequest(); const controller = new AbortController(); const requestId = ++this.metadataRequestId; @@ -331,7 +337,7 @@ export class NewSessionModelControl { status: this.metadataState.hasSnapshot ? "refreshing" : "loading", }); - void requestNewSessionMetadata(client, agentId, controller.signal).then( + void requestNewSessionMetadata(client, agentId, controller.signal, modelsListAdvertised).then( (result) => { // Aborted transports may still resolve. Only the request that still // owns the control may publish catalog data or restore preferences. @@ -470,7 +476,11 @@ export class NewSessionModelControl { this.notify(); return; } - this.startMetadataRequest(client, normalizedAgentId); + this.startMetadataRequest( + client, + normalizedAgentId, + isGatewayMethodAdvertised(snapshot, "models.list"), + ); } isRestoringPreference(): boolean {